1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-10 11:24:35 +00:00

Compare commits

...

190 Commits

Author SHA1 Message Date
semantic-release-bot
abce135da2 chore(release): 0.15.2 [skip ci]
## [0.15.2](https://github.com/sasjs/server/compare/v0.15.1...v0.15.2) (2022-08-10)

### Bug Fixes

* remove vulnerabitities ([f27ac51](f27ac51fc4))
2022-08-10 11:28:07 +00:00
Allan Bowe
a6c014946a Merge pull request #252 from sasjs/fix-vulnerabilities
fix: remove vulnerabitities
2022-08-10 12:23:23 +01:00
f27ac51fc4 fix: remove vulnerabitities 2022-08-10 16:10:37 +05:00
semantic-release-bot
cb5be1be21 chore(release): 0.15.1 [skip ci]
## [0.15.1](https://github.com/sasjs/server/compare/v0.15.0...v0.15.1) (2022-08-10)

### Bug Fixes

* **web:** fix UI responsiveness ([d99fdd1](d99fdd1ec7))
2022-08-10 10:34:36 +00:00
Allan Bowe
d90fa9e5dd Merge pull request #251 from sasjs/issue-250
fix(web): fix UI responsiveness
2022-08-10 11:29:41 +01:00
d99fdd1ec7 fix(web): fix UI responsiveness 2022-08-10 15:18:05 +05:00
semantic-release-bot
399b5edad0 chore(release): 0.15.0 [skip ci]
# [0.15.0](https://github.com/sasjs/server/compare/v0.14.1...v0.15.0) (2022-08-05)

### Bug Fixes

* after selecting file in sidebar collapse sidebar in mobile view ([e215958](e215958b8b))
* improve mobile view for studio page ([c67d3ee](c67d3ee2f1))
* improve responsiveness for mobile view ([6ef40b9](6ef40b954a))
* improve user experience for adding permissions ([7a162ed](7a162eda8f))
* show logout button only when user is logged in ([9227cd4](9227cd449d))

### Features

* add multiple permission for same combination of type and principal at once ([754704b](754704bca8))
2022-08-05 09:59:19 +00:00
Allan Bowe
1dbc12e96b Merge pull request #249 from sasjs/issue-225
feat: add multiple permission for same combination of type and principal at once
2022-08-05 10:55:32 +01:00
e215958b8b fix: after selecting file in sidebar collapse sidebar in mobile view 2022-08-05 14:18:59 +05:00
9227cd449d fix: show logout button only when user is logged in 2022-08-05 01:22:27 +05:00
c67d3ee2f1 fix: improve mobile view for studio page 2022-08-05 01:10:15 +05:00
6ef40b954a fix: improve responsiveness for mobile view 2022-08-04 22:57:21 +05:00
semantic-release-bot
0d913baff1 chore(release): 0.14.1 [skip ci]
## [0.14.1](https://github.com/sasjs/server/compare/v0.14.0...v0.14.1) (2022-08-04)

### Bug Fixes

* **apps:** App Stream logo fix ([87c03c5](87c03c5f8d))
* **cookie:** XSRF cookie is removed and passed token in head section ([77f8d30](77f8d30baf))
* **env:** check added for not providing WHITELIST ([5966016](5966016853))
* **web:** show login on logged-out state ([f7fcc77](f7fcc7741a))
2022-08-04 12:10:31 +00:00
Allan Bowe
3671736c3d Merge pull request #248 from sasjs/cookies-management
fix(cookie): XSRF cookie is removed and passed token in head section
2022-08-04 13:06:30 +01:00
34cd84d8a9 chore: improve interface for add permission response 2022-08-04 16:34:15 +05:00
Saad Jutt
f7fcc7741a fix(web): show login on logged-out state 2022-08-04 05:39:28 +05:00
Saad Jutt
18052fdbf6 test: fixed failed specs 2022-08-04 04:01:51 +05:00
Saad Jutt
5966016853 fix(env): check added for not providing WHITELIST 2022-08-04 03:32:04 +05:00
Saad Jutt
87c03c5f8d fix(apps): App Stream logo fix 2022-08-04 03:03:27 +05:00
7a162eda8f fix: improve user experience for adding permissions 2022-08-04 02:51:59 +05:00
754704bca8 feat: add multiple permission for same combination of type and principal at once 2022-08-03 23:26:31 +05:00
Saad Jutt
77f8d30baf fix(cookie): XSRF cookie is removed and passed token in head section 2022-08-03 03:38:11 +05:00
semantic-release-bot
78bea7c154 chore(release): 0.14.0 [skip ci]
# [0.14.0](https://github.com/sasjs/server/compare/v0.13.3...v0.14.0) (2022-08-02)

### Bug Fixes

* add restriction on  add/remove user to public group ([d3a516c](d3a516c36e))
* call jwt.verify in synchronous way ([254bc07](254bc07da7))

### Features

* add public group to DB on seed ([c3e3bef](c3e3befc17))
* bypass authentication when route is enabled for public group ([68515f9](68515f95a6))
2022-08-02 19:08:38 +00:00
Saad Jutt
9c3b155c12 Merge pull request #246 from sasjs/issue-240
feat: bypass authentication when route is enabled for public group
2022-08-03 00:03:43 +05:00
Allan Bowe
98e501334f Update seedDB.ts 2022-08-02 19:33:16 +01:00
Allan Bowe
bbfd53e79e Update group.spec.ts 2022-08-02 19:32:44 +01:00
254bc07da7 fix: call jwt.verify in synchronous way 2022-08-02 23:05:42 +05:00
f978814ca7 chore: code refactor 2022-08-02 22:16:41 +05:00
68515f95a6 feat: bypass authentication when route is enabled for public group 2022-08-02 18:06:33 +05:00
d3a516c36e fix: add restriction on add/remove user to public group 2022-08-02 18:05:28 +05:00
c3e3befc17 feat: add public group to DB on seed 2022-08-02 18:04:00 +05:00
semantic-release-bot
275de9478e chore(release): 0.13.3 [skip ci]
## [0.13.3](https://github.com/sasjs/server/compare/v0.13.2...v0.13.3) (2022-08-02)

### Bug Fixes

* show non-admin user his own permissions only ([8a3054e](8a3054e19a))
* update schema of Permission ([5d5a9d3](5d5a9d3788))
2022-08-02 12:01:53 +00:00
Allan Bowe
1a3ef62cb2 Merge pull request #243 from sasjs/issue-241
fix: show non-admin user his own permissions only
2022-08-02 12:57:57 +01:00
semantic-release-bot
9eb5f3ca4d chore(release): 0.13.2 [skip ci]
## [0.13.2](https://github.com/sasjs/server/compare/v0.13.1...v0.13.2) (2022-08-01)

### Bug Fixes

* adding ls=max to reduce log size and improve readability ([916947d](916947dffa))
2022-08-01 22:42:31 +00:00
Allan Bowe
916947dffa fix: adding ls=max to reduce log size and improve readability 2022-08-01 22:38:31 +00:00
79b7827b7c chore: update tabs label in setting page 2022-08-01 23:01:05 +05:00
37e1aa9b61 chore: spec fixed 2022-08-01 22:54:31 +05:00
7e504008b7 chore: quick fix 2022-08-01 22:50:18 +05:00
5d5a9d3788 fix: update schema of Permission 2022-08-01 21:33:10 +05:00
semantic-release-bot
7c79d6479c chore(release): 0.13.1 [skip ci]
## [0.13.1](https://github.com/sasjs/server/compare/v0.13.0...v0.13.1) (2022-07-31)

### Bug Fixes

* adding options to prevent unwanted windows on windows.  Closes [#244](https://github.com/sasjs/server/issues/244) ([77db14c](77db14c690))
2022-07-31 17:09:11 +00:00
Allan Bowe
3e635f422a Merge pull request #245 from sasjs/allanbowe/avoid-batch-sas-window-244
fix: adding options to prevent unwanted windows on windows.  Closes #244
2022-07-31 18:05:05 +01:00
Allan Bowe
77db14c690 fix: adding options to prevent unwanted windows on windows. Closes #244 2022-07-31 16:58:33 +00:00
b7dff341f0 chore: fix specs 2022-07-30 00:18:02 +05:00
8a3054e19a fix: show non-admin user his own permissions only 2022-07-30 00:01:15 +05:00
semantic-release-bot
a531de2adb chore(release): 0.13.0 [skip ci]
# [0.13.0](https://github.com/sasjs/server/compare/v0.12.1...v0.13.0) (2022-07-28)

### Bug Fixes

* autofocus input field and submit on enter ([7681722](7681722e5a))
* move api button to user menu ([8de032b](8de032b543))

### Features

* add action and command to editor ([706e228](706e228a8e))
2022-07-28 19:27:12 +00:00
Allan Bowe
c458d94493 Merge pull request #239 from sasjs/issue-238
fix: improve user experience in the studio
2022-07-28 20:21:48 +01:00
706e228a8e feat: add action and command to editor 2022-07-28 23:56:44 +05:00
7681722e5a fix: autofocus input field and submit on enter 2022-07-28 23:55:59 +05:00
8de032b543 fix: move api button to user menu 2022-07-28 23:54:40 +05:00
semantic-release-bot
998ef213e9 chore(release): 0.12.1 [skip ci]
## [0.12.1](https://github.com/sasjs/server/compare/v0.12.0...v0.12.1) (2022-07-26)

### Bug Fixes

* **web:** disable launch icon button when file content is not saved ([c574b42](c574b42235))
* **web:** saveAs functionality fixed in studio page ([3c987c6](3c987c61dd))
* **web:** show original name as default name in rename file/folder modal ([9640f65](9640f65264))
* **web:** webout tab item fixed in studio page ([7cdffe3](7cdffe30e3))
* **web:** when no file is selected save the editor content to local storage ([3b1fcb9](3b1fcb937d))
2022-07-26 20:52:05 +00:00
Allan Bowe
f8b0f98678 Merge pull request #236 from sasjs/fix-studio
fix: issues fixed in studio page
2022-07-26 21:48:20 +01:00
9640f65264 fix(web): show original name as default name in rename file/folder modal 2022-07-27 01:44:13 +05:00
c574b42235 fix(web): disable launch icon button when file content is not saved 2022-07-27 01:42:46 +05:00
468d1a929d chore(web): quick fixes 2022-07-27 00:47:38 +05:00
7cdffe30e3 fix(web): webout tab item fixed in studio page 2022-07-26 23:53:07 +05:00
3b1fcb937d fix(web): when no file is selected save the editor content to local storage 2022-07-26 23:30:41 +05:00
3c987c61dd fix(web): saveAs functionality fixed in studio page 2022-07-26 23:15:42 +05:00
0a780697da chore(web): move hooks to hooks folder 2022-07-26 23:14:29 +05:00
83d819df53 chore(web): created custom useStateWithCallback hook 2022-07-26 23:12:55 +05:00
semantic-release-bot
95df2b21d6 chore(release): 0.12.0 [skip ci]
# [0.12.0](https://github.com/sasjs/server/compare/v0.11.5...v0.12.0) (2022-07-26)

### Bug Fixes

* fileTree api response to include an additional attribute isFolder ([0f19384](0f19384999))
* remove drive component ([06d7c91](06d7c91fc3))

### Features

* add api end point for delete folder ([08e0c61](08e0c61e0f))
* add sidebar(drive) to left of studio ([6c35412](6c35412d2f))
* created api endpoint for adding empty folder in drive ([941917e](941917e508))
* implemented api for renaming file/folder ([fdcaba9](fdcaba9d56))
* implemented delete file/folder functionality ([177675b](177675bc89))
* implemented functionality for adding file/folder from sidebar context menu ([0ce94a5](0ce94a553e))
* implemented the functionality for renaming file/folder from context menu ([7010a6a](7010a6a120))
* prevent user from leaving studio page when there are unsaved changes ([6c75502](6c7550286b))
* **web:** add difference view editor in studio ([420a61a](420a61a5a6))
2022-07-26 14:29:41 +00:00
Allan Bowe
accdf914f1 Merge pull request #235 from sasjs/issue-198
feat: deprecate drive and add sidebar for file navigation to studio
2022-07-26 15:25:26 +01:00
15bdd2d7f0 chore: close file menu after clicking on diff editor menu item 2022-07-26 14:54:06 +05:00
2ce947d216 chore: code fixes 2022-07-26 14:16:27 +05:00
ce2114e3f6 chore: code fixes 2022-07-26 00:07:11 +05:00
6c7550286b feat: prevent user from leaving studio page when there are unsaved changes 2022-07-25 22:41:05 +05:00
2360e104bd chore: reduce the padding between tree items 2022-07-25 15:11:02 +05:00
420a61a5a6 feat(web): add difference view editor in studio 2022-07-25 15:01:04 +05:00
04e0f9efe3 chore: merge main into issue-198 2022-07-22 22:31:32 +05:00
99172cd9ed chore: add specs 2022-07-22 22:18:03 +05:00
57daad0c26 chore: error response codes for drive api 2022-07-22 16:58:26 +05:00
cc1e4543fc chore: add specs 2022-07-21 23:03:56 +05:00
03cb89d14f chore: code fixes 2022-07-21 23:03:40 +05:00
72140d73c2 chore: modified folderParamValidation method 2022-07-21 14:08:44 +05:00
efcefd2a42 chore: quick fix 2022-07-21 13:25:46 +05:00
06d7c91fc3 fix: remove drive component 2022-07-20 23:53:42 +05:00
7010a6a120 feat: implemented the functionality for renaming file/folder from context menu 2022-07-20 23:46:39 +05:00
fdcaba9d56 feat: implemented api for renaming file/folder 2022-07-20 23:45:11 +05:00
48688a6547 chore: update swagger docs 2022-07-20 16:52:49 +05:00
0ce94a553e feat: implemented functionality for adding file/folder from sidebar context menu 2022-07-20 16:45:45 +05:00
941917e508 feat: created api endpoint for adding empty folder in drive 2022-07-20 16:43:43 +05:00
semantic-release-bot
5706371ffd chore(release): 0.11.5 [skip ci]
## [0.11.5](https://github.com/sasjs/server/compare/v0.11.4...v0.11.5) (2022-07-19)

### Bug Fixes

* Revert "fix(security): missing cookie flags are added" ([ce5218a](ce5218a227))
2022-07-19 23:03:43 +00:00
Saad Jutt
ce5218a227 fix: Revert "fix(security): missing cookie flags are added"
This reverts commit 526402fd73.
2022-07-20 03:58:25 +05:00
semantic-release-bot
8b62755f39 chore(release): 0.11.4 [skip ci]
## [0.11.4](https://github.com/sasjs/server/compare/v0.11.3...v0.11.4) (2022-07-19)

### Bug Fixes

* **security:** missing cookie flags are added ([526402f](526402fd73))
2022-07-19 21:06:05 +00:00
Allan Bowe
cb84c3ebbb Merge pull request #234 from sasjs/issue147
fix(security): missing cookie flags are added
2022-07-19 22:02:05 +01:00
Saad Jutt
526402fd73 fix(security): missing cookie flags are added 2022-07-20 01:40:31 +05:00
177675bc89 feat: implemented delete file/folder functionality 2022-07-19 22:49:34 +05:00
721165ff12 chore: add delete confirmation modal and use it in permission component 2022-07-19 22:48:22 +05:00
08e0c61e0f feat: add api end point for delete folder 2022-07-19 22:41:03 +05:00
semantic-release-bot
1b234eb2b1 chore(release): 0.11.3 [skip ci]
## [0.11.3](https://github.com/sasjs/server/compare/v0.11.2...v0.11.3) (2022-07-19)

### Bug Fixes

* filePath fix in code.js file for windows ([2995121](299512135d))
2022-07-19 14:50:19 +00:00
Allan Bowe
ef25eec11f Merge pull request #233 from sasjs/issue-227
fix: filePath fix in code.js file for windows
2022-07-19 15:46:18 +01:00
3e53f70928 chore: update swagger docs 2022-07-19 16:14:40 +05:00
0f19384999 fix: fileTree api response to include an additional attribute isFolder 2022-07-19 16:13:46 +05:00
63dd6813c0 chore: lint fix 2022-07-19 13:07:34 +05:00
299512135d fix: filePath fix in code.js file for windows 2022-07-19 13:00:33 +05:00
6c35412d2f feat: add sidebar(drive) to left of studio 2022-07-18 22:39:09 +05:00
27410bc32b chore: add file path input modal 2022-07-18 22:37:32 +05:00
849b2dd468 chore: add custom tree view component 2022-07-18 22:32:10 +05:00
semantic-release-bot
a1a182698e chore(release): 0.11.2 [skip ci]
## [0.11.2](https://github.com/sasjs/server/compare/v0.11.1...v0.11.2) (2022-07-18)

### Bug Fixes

* apply icon option only for sas.exe ([d2ddd8a](d2ddd8aaca))
2022-07-18 12:39:49 +00:00
Allan Bowe
4be692b24b Merge pull request #232 from sasjs/issue229
fix: apply icon option only for sas.exe
2022-07-18 13:34:21 +01:00
Allan Bowe
d2ddd8aaca fix: apply icon option only for sas.exe 2022-07-18 12:33:52 +00:00
semantic-release-bot
3a45e8f525 chore(release): 0.11.1 [skip ci]
## [0.11.1](https://github.com/sasjs/server/compare/v0.11.0...v0.11.1) (2022-07-18)

### Bug Fixes

* bank operator ([aa02741](aa027414ed))
* ensuring nosplash option only applies for sas.exe ([65e6de9](65e6de9663)), closes [#229](https://github.com/sasjs/server/issues/229)
2022-07-18 12:14:31 +00:00
Allan Bowe
c0e2f55a7b Merge pull request #231 from sasjs/issue229
fix: bank operator
2022-07-18 13:10:30 +01:00
Allan Bowe
aa027414ed fix: bank operator 2022-07-18 12:09:54 +00:00
Allan Bowe
8c4c52b1a9 Merge pull request #230 from sasjs/issue229
fix: ensuring nosplash option only applies for sas.exe
2022-07-18 12:58:15 +01:00
Allan Bowe
ff420434ae chore: removing line added automatically 2022-07-18 11:57:19 +00:00
Allan Bowe
65e6de9663 fix: ensuring nosplash option only applies for sas.exe
Closes #229
2022-07-18 11:55:35 +00:00
semantic-release-bot
2e53d43e11 chore(release): 0.11.0 [skip ci]
# [0.11.0](https://github.com/sasjs/server/compare/v0.10.0...v0.11.0) (2022-07-16)

### Bug Fixes

* **logs:** logs location is configurable ([e024a92](e024a92f16))

### Features

* **logs:** logs to file with rotating + code split into files ([92fda18](92fda183f3))
2022-07-16 21:58:08 +00:00
Allan Bowe
3795f748a7 Merge pull request #228 from sasjs/issue217
Issue217
2022-07-16 22:54:13 +01:00
Saad Jutt
e024a92f16 fix(logs): logs location is configurable 2022-07-16 05:07:00 +05:00
Saad Jutt
92fda183f3 feat(logs): logs to file with rotating + code split into files 2022-07-16 04:42:54 +05:00
Saad Jutt
6f2e6efd03 chore: fixed few vulnerabilites 2022-07-16 03:30:29 +05:00
30d7a65358 chore: fix breaking changes caused by react-router-dom update 2022-07-15 18:42:59 +05:00
5e930f14d2 chore: bump mui/icons-material and react-router-dom versions 2022-07-15 18:41:11 +05:00
9bc68b1cdc chore: update swagger docs 2022-07-15 18:40:02 +05:00
Allan Bowe
3b4e9d20d4 Create FUNDING.yml 2022-07-08 20:51:10 +01:00
semantic-release-bot
4a67d0c63a chore(release): 0.10.0 [skip ci]
# [0.10.0](https://github.com/sasjs/server/compare/v0.9.0...v0.10.0) (2022-07-06)

### Bug Fixes

* add authorize middleware for appStreams ([e54a09d](e54a09db19))
* add isAdmin attribute to return response of get session and login requests ([bdf63df](bdf63df1d9))
* add permission authorization middleware to only specific routes ([f3dfc70](f3dfc7083f))
* bumping core and running lint ([a2d1396](a2d1396057))
* controller fixed for deleting permission ([b5f595a](b5f595a25c))
* do not show admin users in add permission modal ([a75edba](a75edbaa32))
* export GroupResponse interface ([38a7db8](38a7db8514))
* move permission filter modal to separate file and icons for different actions ([d000f75](d000f7508f))
* principalId type changed to  number from any ([4fcc191](4fcc191ce9))
* remove clientId from principal types ([0781ddd](0781ddd64e))
* remove duplicates principals from permission filter modal ([5b319f9](5b319f9ad1))
* show loading spinner in studio while executing code ([496247d](496247d0b9))
* show permission component only in server mode ([f863b81](f863b81a7d))
* update permission model ([39fc908](39fc908de1))
* update permission response ([e516b77](e516b7716d))
* **web:** only admin should be able to add, update or delete permission ([be8635c](be8635ccc5))

### Features

* add api endpoint for deleting permission ([0171344](01713440a4))
* add api endpoint for updating permission setting ([540f54f](540f54fb77))
* add authorize middleware for validating permissions ([7d916ec](7d916ec3e9))
* add basic UI for settings and permissions ([5652325](5652325452))
* add documentation link under usename dropdown menu ([eeb63b3](eeb63b330c))
* add permission model ([6bea1f7](6bea1f7666))
* add UI for updating permission ([e8c21a4](e8c21a43b2))
* add validation for registering permission ([e5200c1](e5200c1000))
* add, remove and update permissions from web component ([97ecfdc](97ecfdc955))
* added get authorizedRoutes api endpoint ([b10e932](b10e932605))
* created modal for adding permission ([1413b18](1413b18508))
* defined register permission and get all permissions api endpoints ([1103ffe](1103ffe07b))
* update swagger docs ([797c2bc](797c2bcc39))
2022-07-06 12:31:43 +00:00
Allan Bowe
dea204e3c5 Merge pull request #221 from sasjs/improve-web-UI
fix: Improve web UI
2022-07-06 13:26:19 +01:00
Allan Bowe
5f9e83759c Merge pull request #215 from sasjs/descs
chore: update descriptions
2022-07-06 13:26:00 +01:00
Allan Bowe
fefe63deb1 Merge pull request #151 from sasjs/issue-139
feat: enable permissions
2022-07-06 13:25:27 +01:00
ddd179bbee chore: added specs for verifying permissions 2022-07-05 16:18:14 +05:00
a10b87930c chore: quick fix 2022-07-05 15:29:44 +05:00
496247d0b9 fix: show loading spinner in studio while executing code 2022-07-05 08:23:51 +05:00
eeb63b330c feat: add documentation link under usename dropdown menu 2022-07-05 08:23:36 +05:00
Saad Jutt
1108d3dd7b chore: quick fix 2022-07-05 05:30:13 +05:00
Saad Jutt
7edb47a4cb chore: build fix 2022-07-05 03:40:54 +05:00
Saad Jutt
451cb4f6dd chore: fixed specs 2022-07-05 03:26:37 +05:00
Saad Jutt
0b759a5594 chore: code fixes API + web 2022-07-05 02:34:33 +05:00
Saad Jutt
5338ffb211 chore: Merge branch 'main' into issue-139 2022-07-04 23:59:51 +05:00
e42fdd3575 chore: conditionally call authorize middleware from authenticateToken 2022-07-04 20:13:46 +05:00
b10e932605 feat: added get authorizedRoutes api endpoint 2022-07-04 19:14:06 +05:00
e54a09db19 fix: add authorize middleware for appStreams 2022-07-04 17:14:17 +05:00
4c35e04802 chore: add snackbar for showing success alert 2022-07-04 16:00:23 +05:00
b5f595a25c fix: controller fixed for deleting permission 2022-07-04 04:27:58 +05:00
semantic-release-bot
a131adbae7 chore(release): 0.9.0 [skip ci]
# [0.9.0](https://github.com/sasjs/server/compare/v0.8.3...v0.9.0) (2022-07-03)

### Features

* removed secrets from env variables ([9c3da56](9c3da56901))
2022-07-03 10:40:36 +00:00
Allan Bowe
a20c3b9719 Merge pull request #220 from sasjs/issue213
feat: removed secrets from env variables
2022-07-03 11:36:24 +01:00
Saad Jutt
eee3a7b084 chore: code refactor 2022-07-03 07:03:15 +05:00
Saad Jutt
9c3da56901 feat: removed secrets from env variables 2022-07-03 06:56:18 +05:00
Allan Bowe
7e6524d7e4 chore: removing badge 2022-07-02 15:12:27 +01:00
Allan Bowe
0ea2690616 adding matrix chat link 2022-07-02 13:10:20 +01:00
semantic-release-bot
b369759f0f chore(release): 0.8.3 [skip ci]
## [0.8.3](https://github.com/sasjs/server/compare/v0.8.2...v0.8.3) (2022-07-02)

### Bug Fixes

* **deploy:** extract first json from zip file ([e290751](e290751c87))
2022-07-02 10:01:26 +00:00
Allan Bowe
ac9a835c5a Merge pull request #219 from sasjs/issue211
fix(deploy): extract first json from zip file
2022-07-02 10:57:16 +01:00
Saad Jutt
e290751c87 fix(deploy): extract first json from zip file 2022-07-02 14:39:33 +05:00
e516b7716d fix: update permission response 2022-07-02 01:03:53 +05:00
f3dfc7083f fix: add permission authorization middleware to only specific routes 2022-07-01 16:50:24 +05:00
7d916ec3e9 feat: add authorize middleware for validating permissions 2022-06-29 23:06:58 +05:00
70f279a49c chore: update swagger.yaml 2022-06-28 09:23:53 +05:00
66a3537271 chore: add specs 2022-06-28 06:50:35 +05:00
ca64c13909 chore: add principal type and permission setting enums 2022-06-28 00:00:04 +05:00
0a73a35547 chore: improve error handling 2022-06-27 23:21:48 +05:00
a75edbaa32 fix: do not show admin users in add permission modal 2022-06-26 01:49:07 +05:00
4ddfec0403 chore: add isAdmin field in user response 2022-06-26 01:48:31 +05:00
35439d7d51 chore: throw error when adding permission for admin user 2022-06-24 23:19:19 +05:00
907aa485fd chore: throw error when creating duplicate permission 2022-06-24 23:15:41 +05:00
888627e1c8 chore: close filterModal after applying/reseting 2022-06-24 22:32:18 +05:00
9cb9e2dd33 chore: add filter based on principal type 2022-06-24 16:28:41 +05:00
54d4bf835d chore: show principal type in permissions list 2022-06-24 15:50:09 +05:00
67fe298fd5 chore: lint fixes 2022-06-24 14:55:05 +05:00
97ecfdc955 feat: add, remove and update permissions from web component 2022-06-24 14:48:57 +05:00
5b319f9ad1 fix: remove duplicates principals from permission filter modal 2022-06-23 23:58:40 +05:00
be8635ccc5 fix(web): only admin should be able to add, update or delete permission 2022-06-23 23:35:06 +05:00
f863b81a7d fix: show permission component only in server mode 2022-06-23 23:14:54 +05:00
bdf63df1d9 fix: add isAdmin attribute to return response of get session and login requests 2022-06-23 22:50:00 +05:00
4c6b9c5e93 Merge branch 'main' into issue-139 2022-06-23 17:21:52 +05:00
Allan Bowe
a2d1396057 fix: bumping core and running lint 2022-06-23 09:00:22 +00:00
Allan Bowe
b2f21eb3ac chore: update descriptions 2022-06-23 08:44:12 +00:00
fa63dc071b chore: update specs and swagger.yaml 2022-05-18 00:29:42 +05:00
e8c21a43b2 feat: add UI for updating permission 2022-05-18 00:20:49 +05:00
1413b18508 feat: created modal for adding permission 2022-05-18 00:05:28 +05:00
dfbd155711 chore: move common interfaces to utils folder 2022-05-18 00:04:37 +05:00
4fcc191ce9 fix: principalId type changed to number from any 2022-05-18 00:03:11 +05:00
d000f7508f fix: move permission filter modal to separate file and icons for different actions 2022-05-17 15:42:29 +05:00
5652325452 feat: add basic UI for settings and permissions 2022-05-16 23:53:30 +05:00
0781ddd64e fix: remove clientId from principal types 2022-05-16 19:56:56 +05:00
7be77cc38a chore: remvoe code redundancy and add specs for get permissions api endpoint 2022-05-10 07:05:59 +05:00
98b8a75148 chore: add specs for delete permission api endpoint 2022-05-10 06:40:34 +05:00
72a3197a06 chore: add spec for update permission when permission with provided id not exists 2022-05-10 06:25:52 +05:00
fce05d6959 chore: add spec for invalid principal type 2022-05-10 06:18:19 +05:00
1aec3abd28 chore: add specs for update permission api endpoint 2022-05-10 06:11:24 +05:00
9136c95013 chore: write specs for create permission api endpoint 2022-05-09 13:08:15 +05:00
Saad Jutt
89b32e70ff refactor: code in permission controller 2022-04-30 03:49:26 +05:00
01713440a4 feat: add api endpoint for deleting permission 2022-04-30 01:16:52 +05:00
540f54fb77 feat: add api endpoint for updating permission setting 2022-04-30 01:02:47 +05:00
bf906aa544 Merge branch 'main' into issue-139 2022-04-29 15:41:35 +05:00
797c2bcc39 feat: update swagger docs 2022-04-29 15:31:24 +05:00
1103ffe07b feat: defined register permission and get all permissions api endpoints 2022-04-29 15:30:41 +05:00
e5200c1000 feat: add validation for registering permission 2022-04-29 15:28:29 +05:00
38a7db8514 fix: export GroupResponse interface 2022-04-29 15:27:34 +05:00
39fc908de1 fix: update permission model 2022-04-29 15:26:26 +05:00
be009d5b02 Merge branch 'main' into issue-139 2022-04-29 00:32:36 +05:00
6bea1f7666 feat: add permission model 2022-04-28 21:18:23 +05:00
101 changed files with 8531 additions and 4425 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [sasjs]

View File

@@ -1,3 +1,225 @@
## [0.15.2](https://github.com/sasjs/server/compare/v0.15.1...v0.15.2) (2022-08-10)
### Bug Fixes
* remove vulnerabitities ([f27ac51](https://github.com/sasjs/server/commit/f27ac51fc4beb21070d0ab551cfdaec1f6ba39e0))
## [0.15.1](https://github.com/sasjs/server/compare/v0.15.0...v0.15.1) (2022-08-10)
### Bug Fixes
* **web:** fix UI responsiveness ([d99fdd1](https://github.com/sasjs/server/commit/d99fdd1ec7991b94a0d98338d7a7a6216f46ce45))
# [0.15.0](https://github.com/sasjs/server/compare/v0.14.1...v0.15.0) (2022-08-05)
### Bug Fixes
* after selecting file in sidebar collapse sidebar in mobile view ([e215958](https://github.com/sasjs/server/commit/e215958b8b05d7a8ce9d82395e0640b5b37fb40d))
* improve mobile view for studio page ([c67d3ee](https://github.com/sasjs/server/commit/c67d3ee2f102155e2e9781e13d5d33c1ab227cb4))
* improve responsiveness for mobile view ([6ef40b9](https://github.com/sasjs/server/commit/6ef40b954a87ebb0a2621119064f38d58ea85148))
* improve user experience for adding permissions ([7a162ed](https://github.com/sasjs/server/commit/7a162eda8fc60383ff647d93e6611799e2e6af7a))
* show logout button only when user is logged in ([9227cd4](https://github.com/sasjs/server/commit/9227cd449dc46fd960a488eb281804a9b9ffc284))
### Features
* add multiple permission for same combination of type and principal at once ([754704b](https://github.com/sasjs/server/commit/754704bca89ecbdbcc3bd4ef04b94124c4f24167))
## [0.14.1](https://github.com/sasjs/server/compare/v0.14.0...v0.14.1) (2022-08-04)
### Bug Fixes
* **apps:** App Stream logo fix ([87c03c5](https://github.com/sasjs/server/commit/87c03c5f8dbdfc151d4ff3722ecbcd3f7e409aea))
* **cookie:** XSRF cookie is removed and passed token in head section ([77f8d30](https://github.com/sasjs/server/commit/77f8d30baf9b1077279c29f1c3e5ca02a5436bc0))
* **env:** check added for not providing WHITELIST ([5966016](https://github.com/sasjs/server/commit/5966016853369146b27ac5781808cb51d65c887f))
* **web:** show login on logged-out state ([f7fcc77](https://github.com/sasjs/server/commit/f7fcc7741aa2af93a4a2b1e651003704c9bbff0c))
# [0.14.0](https://github.com/sasjs/server/compare/v0.13.3...v0.14.0) (2022-08-02)
### Bug Fixes
* add restriction on add/remove user to public group ([d3a516c](https://github.com/sasjs/server/commit/d3a516c36e45aa1cc76c30c744e6a0e5bd553165))
* call jwt.verify in synchronous way ([254bc07](https://github.com/sasjs/server/commit/254bc07da744a9708109bfb792be70aa3f6284f4))
### Features
* add public group to DB on seed ([c3e3bef](https://github.com/sasjs/server/commit/c3e3befc17102ee1754e1403193040b4f79fb2a7))
* bypass authentication when route is enabled for public group ([68515f9](https://github.com/sasjs/server/commit/68515f95a65d422e29c0ed6028f3ea0ae8d9b1bf))
## [0.13.3](https://github.com/sasjs/server/compare/v0.13.2...v0.13.3) (2022-08-02)
### Bug Fixes
* show non-admin user his own permissions only ([8a3054e](https://github.com/sasjs/server/commit/8a3054e19ade82e2792cfb0f2a8af9e502c5eb52))
* update schema of Permission ([5d5a9d3](https://github.com/sasjs/server/commit/5d5a9d3788281d75c56f68f0dff231abc9c9c275))
## [0.13.2](https://github.com/sasjs/server/compare/v0.13.1...v0.13.2) (2022-08-01)
### Bug Fixes
* adding ls=max to reduce log size and improve readability ([916947d](https://github.com/sasjs/server/commit/916947dffacd902ff23ac3e899d1bf5ab6238b75))
## [0.13.1](https://github.com/sasjs/server/compare/v0.13.0...v0.13.1) (2022-07-31)
### Bug Fixes
* adding options to prevent unwanted windows on windows. Closes [#244](https://github.com/sasjs/server/issues/244) ([77db14c](https://github.com/sasjs/server/commit/77db14c690e18145d733ac2b0d646ab0dbe4d521))
# [0.13.0](https://github.com/sasjs/server/compare/v0.12.1...v0.13.0) (2022-07-28)
### Bug Fixes
* autofocus input field and submit on enter ([7681722](https://github.com/sasjs/server/commit/7681722e5afdc2df0c9eed201b05add3beda92a7))
* move api button to user menu ([8de032b](https://github.com/sasjs/server/commit/8de032b5431b47daabcf783c47ff078bf817247d))
### Features
* add action and command to editor ([706e228](https://github.com/sasjs/server/commit/706e228a8e1924786fd9dc97de387974eda504b1))
## [0.12.1](https://github.com/sasjs/server/compare/v0.12.0...v0.12.1) (2022-07-26)
### Bug Fixes
* **web:** disable launch icon button when file content is not saved ([c574b42](https://github.com/sasjs/server/commit/c574b4223591c4a6cd3ef5e146ce99cd8f7c9190))
* **web:** saveAs functionality fixed in studio page ([3c987c6](https://github.com/sasjs/server/commit/3c987c61ddc258f991e2bf38c1f16a0c4248d6ae))
* **web:** show original name as default name in rename file/folder modal ([9640f65](https://github.com/sasjs/server/commit/9640f6526496f3564664ccb1f834d0f659dcad4e))
* **web:** webout tab item fixed in studio page ([7cdffe3](https://github.com/sasjs/server/commit/7cdffe30e36e5cad0284f48ea97925958e12704c))
* **web:** when no file is selected save the editor content to local storage ([3b1fcb9](https://github.com/sasjs/server/commit/3b1fcb937d06d02ab99c9e8dbe307012d48a7a3a))
# [0.12.0](https://github.com/sasjs/server/compare/v0.11.5...v0.12.0) (2022-07-26)
### Bug Fixes
* fileTree api response to include an additional attribute isFolder ([0f19384](https://github.com/sasjs/server/commit/0f193849994f1ac8a071afa8f10af5b46f86663d))
* remove drive component ([06d7c91](https://github.com/sasjs/server/commit/06d7c91fc34620a954df1fd1c682eff370f79ca6))
### Features
* add api end point for delete folder ([08e0c61](https://github.com/sasjs/server/commit/08e0c61e0fd7041d6cded6f4d71fbb410e5615ce))
* add sidebar(drive) to left of studio ([6c35412](https://github.com/sasjs/server/commit/6c35412d2f5180d4e49b12e616576d8b8dacb7d8))
* created api endpoint for adding empty folder in drive ([941917e](https://github.com/sasjs/server/commit/941917e508ece5009135f9dddf99775dd4002f78))
* implemented api for renaming file/folder ([fdcaba9](https://github.com/sasjs/server/commit/fdcaba9d56cddea5d56d7de5a172f1bb49be3db5))
* implemented delete file/folder functionality ([177675b](https://github.com/sasjs/server/commit/177675bc897416f7994dd849dc7bb11ba072efe9))
* implemented functionality for adding file/folder from sidebar context menu ([0ce94a5](https://github.com/sasjs/server/commit/0ce94a553e53bfcdbd6273b26b322095a080a341))
* implemented the functionality for renaming file/folder from context menu ([7010a6a](https://github.com/sasjs/server/commit/7010a6a1201720d0eb4093267a344fb828b90a2f))
* prevent user from leaving studio page when there are unsaved changes ([6c75502](https://github.com/sasjs/server/commit/6c7550286b5f505e9dfe8ca63c62fa1db1b60b2e))
* **web:** add difference view editor in studio ([420a61a](https://github.com/sasjs/server/commit/420a61a5a6b11dcb5eb0a652ea9cecea5c3bee5f))
## [0.11.5](https://github.com/sasjs/server/compare/v0.11.4...v0.11.5) (2022-07-19)
### Bug Fixes
* Revert "fix(security): missing cookie flags are added" ([ce5218a](https://github.com/sasjs/server/commit/ce5218a2278cc750f2b1032024685dc6cd72f796))
## [0.11.4](https://github.com/sasjs/server/compare/v0.11.3...v0.11.4) (2022-07-19)
### Bug Fixes
* **security:** missing cookie flags are added ([526402f](https://github.com/sasjs/server/commit/526402fd73407ee4fa2d31092111a7e6a1741487))
## [0.11.3](https://github.com/sasjs/server/compare/v0.11.2...v0.11.3) (2022-07-19)
### Bug Fixes
* filePath fix in code.js file for windows ([2995121](https://github.com/sasjs/server/commit/299512135d77c2ac9e34853cf35aee6f2e1d4da4))
## [0.11.2](https://github.com/sasjs/server/compare/v0.11.1...v0.11.2) (2022-07-18)
### Bug Fixes
* apply icon option only for sas.exe ([d2ddd8a](https://github.com/sasjs/server/commit/d2ddd8aacadfdd143026881f2c6ae8c6b277610a))
## [0.11.1](https://github.com/sasjs/server/compare/v0.11.0...v0.11.1) (2022-07-18)
### Bug Fixes
* bank operator ([aa02741](https://github.com/sasjs/server/commit/aa027414ed3ce51f1014ef36c4191e064b2e963d))
* ensuring nosplash option only applies for sas.exe ([65e6de9](https://github.com/sasjs/server/commit/65e6de966383fe49a919b1f901d77c7f1e402c9b)), closes [#229](https://github.com/sasjs/server/issues/229)
# [0.11.0](https://github.com/sasjs/server/compare/v0.10.0...v0.11.0) (2022-07-16)
### Bug Fixes
* **logs:** logs location is configurable ([e024a92](https://github.com/sasjs/server/commit/e024a92f165990e08db8aa26ee326dbcb30e2e46))
### Features
* **logs:** logs to file with rotating + code split into files ([92fda18](https://github.com/sasjs/server/commit/92fda183f3f0f3956b7c791669eb8dd52c389d1b))
# [0.10.0](https://github.com/sasjs/server/compare/v0.9.0...v0.10.0) (2022-07-06)
### Bug Fixes
* add authorize middleware for appStreams ([e54a09d](https://github.com/sasjs/server/commit/e54a09db19ec8690e54a40760531a4e06d250974))
* add isAdmin attribute to return response of get session and login requests ([bdf63df](https://github.com/sasjs/server/commit/bdf63df1d915892486005ec904807749786b1c0c))
* add permission authorization middleware to only specific routes ([f3dfc70](https://github.com/sasjs/server/commit/f3dfc7083fbfb4b447521341b1a86730fb90b4c0))
* bumping core and running lint ([a2d1396](https://github.com/sasjs/server/commit/a2d13960578014312d2cb5e03145bfd1829d99ec))
* controller fixed for deleting permission ([b5f595a](https://github.com/sasjs/server/commit/b5f595a25c50550d62482409353c7629c5a5c3e0))
* do not show admin users in add permission modal ([a75edba](https://github.com/sasjs/server/commit/a75edbaa327ec2af49523c13996ac283061da7d8))
* export GroupResponse interface ([38a7db8](https://github.com/sasjs/server/commit/38a7db8514de0acd94d74ba96bc1efb732add30c))
* move permission filter modal to separate file and icons for different actions ([d000f75](https://github.com/sasjs/server/commit/d000f7508f6d7384afffafee4179151fca802ca8))
* principalId type changed to number from any ([4fcc191](https://github.com/sasjs/server/commit/4fcc191ce9edc7e4dcd8821fb8019f4eea5db4ea))
* remove clientId from principal types ([0781ddd](https://github.com/sasjs/server/commit/0781ddd64e3b5e5ca39647bb4e4e1a9332a0f4f8))
* remove duplicates principals from permission filter modal ([5b319f9](https://github.com/sasjs/server/commit/5b319f9ad1f941b306db6b9473a2128b2e42bf76))
* show loading spinner in studio while executing code ([496247d](https://github.com/sasjs/server/commit/496247d0b9975097a008cf4d3a999d77648fd930))
* show permission component only in server mode ([f863b81](https://github.com/sasjs/server/commit/f863b81a7d40a1296a061ec93946f204382af2c3))
* update permission model ([39fc908](https://github.com/sasjs/server/commit/39fc908de1945f2aaea18d14e6bce703f6bf0c06))
* update permission response ([e516b77](https://github.com/sasjs/server/commit/e516b7716da5ff7e23350a5f77cfa073b1171175))
* **web:** only admin should be able to add, update or delete permission ([be8635c](https://github.com/sasjs/server/commit/be8635ccc5eb34c3f0a5951c8a0421292ef69c97))
### Features
* add api endpoint for deleting permission ([0171344](https://github.com/sasjs/server/commit/01713440a4fa661b76368785c0ca731f096ac70a))
* add api endpoint for updating permission setting ([540f54f](https://github.com/sasjs/server/commit/540f54fb77b364822da7889dbe75c02242f48a59))
* add authorize middleware for validating permissions ([7d916ec](https://github.com/sasjs/server/commit/7d916ec3e9ef579dde1b73015715cd01098c2018))
* add basic UI for settings and permissions ([5652325](https://github.com/sasjs/server/commit/56523254525a66e756196e90b39a2b8cdadc1518))
* add documentation link under usename dropdown menu ([eeb63b3](https://github.com/sasjs/server/commit/eeb63b330c292afcdd5c8f006882b224c4235068))
* add permission model ([6bea1f7](https://github.com/sasjs/server/commit/6bea1f76668ddb070ad95b3e02c31238af67c346))
* add UI for updating permission ([e8c21a4](https://github.com/sasjs/server/commit/e8c21a43b215f5fced0463b70747cda1191a4e01))
* add validation for registering permission ([e5200c1](https://github.com/sasjs/server/commit/e5200c1000903185dfad9ee49c99583e473c4388))
* add, remove and update permissions from web component ([97ecfdc](https://github.com/sasjs/server/commit/97ecfdc95563c72dbdecaebcb504e5194250a763))
* added get authorizedRoutes api endpoint ([b10e932](https://github.com/sasjs/server/commit/b10e9326058193dd65a57fab2d2f05b7b06096e7))
* created modal for adding permission ([1413b18](https://github.com/sasjs/server/commit/1413b1850838ecc988ab289da4541bde36a9a346))
* defined register permission and get all permissions api endpoints ([1103ffe](https://github.com/sasjs/server/commit/1103ffe07b88496967cb03683b08f058ca3bbb9f))
* update swagger docs ([797c2bc](https://github.com/sasjs/server/commit/797c2bcc39005a05a995be15a150d584fecae259))
# [0.9.0](https://github.com/sasjs/server/compare/v0.8.3...v0.9.0) (2022-07-03)
### Features
* removed secrets from env variables ([9c3da56](https://github.com/sasjs/server/commit/9c3da56901672a818f54267f9defc9f4701ab7fb))
## [0.8.3](https://github.com/sasjs/server/compare/v0.8.2...v0.8.3) (2022-07-02)
### Bug Fixes
* **deploy:** extract first json from zip file ([e290751](https://github.com/sasjs/server/commit/e290751c872d24009482871a8c398e834357dcde))
## [0.8.2](https://github.com/sasjs/server/compare/v0.8.1...v0.8.2) (2022-06-22)

View File

@@ -105,10 +105,6 @@ CERT_CHAIN=certificate.pem (required)
CA_ROOT=fullchain.pem (optional)
# ENV variables required for MODE: `server`
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
@@ -140,6 +136,9 @@ HELMET_CSP_CONFIG_PATH=./csp.config.json
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
LOG_FORMAT_MORGAN=
# This location is for server logs with classical UNIX logrotate behavior
LOG_LOCATION=./sasjs_root/logs
# A comma separated string that defines the available runTimes.
# Priority is given to the runtime that comes first in the string.
# Possible options at the moment are sas and js

View File

@@ -12,10 +12,6 @@ PORT=[5000] default value is 5000
HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
HELMET_COEP=[true|false] if omitted HELMET default will be used
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
RUN_TIMES=[sas|js|sas,js|js,sas] default considered as sas
@@ -24,4 +20,5 @@ NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
SASJS_ROOT=./sasjs_root
LOG_FORMAT_MORGAN=common
LOG_FORMAT_MORGAN=common
LOG_LOCATION=./sasjs_root/logs

1769
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore",
"prestart": "npm run initial",
"prebuild": "npm run initial",
"start": "nodemon ./src/server.ts",
"start": "NODE_ENV=development nodemon ./src/server.ts",
"start:prod": "node ./build/src/server.js",
"build": "rimraf build && tsc",
"postbuild": "npm run copy:files",
@@ -47,7 +47,7 @@
},
"author": "4GL Ltd",
"dependencies": {
"@sasjs/core": "^4.27.3",
"@sasjs/core": "^4.31.3",
"@sasjs/utils": "2.42.1",
"bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0",
@@ -62,7 +62,8 @@
"mongoose": "^6.0.12",
"mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0",
"multer": "^1.4.3",
"multer": "^1.4.5-lts.1",
"rotating-file-stream": "^3.0.4",
"swagger-ui-express": "4.3.0",
"unzipper": "^0.10.11",
"url": "^0.10.3"

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,12 @@ import {
readFile,
SASJsFileType
} from '@sasjs/utils'
import { apiRoot, sysInitCompiledPath } from '../src/utils'
import { apiRoot, sysInitCompiledPath } from '../src/utils/file'
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
const compiledSystemInit = async (systemInit: string) =>
'options ps=max;\n' +
'options ls=max ps=max;\n' +
(await loadDependenciesFile({
fileContent: systemInit,
type: SASJsFileType.job,

View File

@@ -8,7 +8,11 @@ import {
listFilesInFolder
} from '@sasjs/utils'
import { apiRoot, sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils'
import {
apiRoot,
sasJSCoreMacros,
sasJSCoreMacrosInfo
} from '../src/utils/file'
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')

View File

@@ -0,0 +1,21 @@
import { Express } from 'express'
import cors from 'cors'
import { CorsType } from '../utils'
export const configureCors = (app: Express) => {
const { CORS, WHITELIST } = process.env
if (CORS === CorsType.ENABLED) {
const whiteList: string[] = []
WHITELIST?.split(' ')
?.filter((url) => !!url)
.forEach((url) => {
if (url.startsWith('http'))
// removing trailing slash of URLs listing for CORS
whiteList.push(url.replace(/\/$/, ''))
})
console.log('All CORS Requests are enabled for:', whiteList)
app.use(cors({ credentials: true, origin: whiteList }))
}
}

View File

@@ -0,0 +1,32 @@
import { Express } from 'express'
import mongoose from 'mongoose'
import session from 'express-session'
import MongoStore from 'connect-mongo'
import { ModeType } from '../utils'
import { cookieOptions } from '../app'
export const configureExpressSession = (app: Express) => {
const { MODE } = process.env
if (MODE === ModeType.Server) {
let store: MongoStore | undefined
if (process.env.NODE_ENV !== 'test') {
store = MongoStore.create({
client: mongoose.connection!.getClient() as any,
collectionName: 'sessions'
})
}
app.use(
session({
secret: process.secrets.SESSION_SECRET,
saveUninitialized: false, // don't create session until something stored
resave: false, //don't save session if unmodified
store,
cookie: cookieOptions
})
)
}
}

View File

@@ -0,0 +1,33 @@
import path from 'path'
import { Express } from 'express'
import morgan from 'morgan'
import { createStream } from 'rotating-file-stream'
import { generateTimestamp } from '@sasjs/utils'
import { getLogFolder } from '../utils'
export const configureLogger = (app: Express) => {
const { LOG_FORMAT_MORGAN } = process.env
let options
if (
process.env.NODE_ENV !== 'development' &&
process.env.NODE_ENV !== 'test'
) {
const timestamp = generateTimestamp()
const filename = `${timestamp}.log`
const logsFolder = getLogFolder()
// create a rotating write stream
var accessLogStream = createStream(filename, {
interval: '1d', // rotate daily
path: logsFolder
})
console.log('Writing Logs to :', path.join(logsFolder, filename))
options = { stream: accessLogStream }
}
// setup the logger
app.use(morgan(LOG_FORMAT_MORGAN as string, options))
}

View File

@@ -0,0 +1,26 @@
import { Express } from 'express'
import { getEnvCSPDirectives } from '../utils/parseHelmetConfig'
import { HelmetCoepType, ProtocolType } from '../utils'
import helmet from 'helmet'
export const configureSecurity = (app: Express) => {
const { PROTOCOL, HELMET_CSP_CONFIG_PATH, HELMET_COEP } = process.env
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
HELMET_CSP_CONFIG_PATH
)
if (PROTOCOL === ProtocolType.HTTP)
cspConfigJson['upgrade-insecure-requests'] = null
app.use(
helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
...cspConfigJson
}
},
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
})
)
}

View File

@@ -0,0 +1,4 @@
export * from './configureCors'
export * from './configureExpressSession'
export * from './configureLogger'
export * from './configureSecurity'

View File

@@ -1,30 +1,26 @@
import path from 'path'
import express, { ErrorRequestHandler } from 'express'
import csrf from 'csurf'
import session from 'express-session'
import MongoStore from 'connect-mongo'
import morgan from 'morgan'
import csrf, { CookieOptions } from 'csurf'
import cookieParser from 'cookie-parser'
import dotenv from 'dotenv'
import cors from 'cors'
import helmet from 'helmet'
import {
connectDB,
copySASjsCore,
CorsType,
getWebBuildFolder,
HelmetCoepType,
instantiateLogger,
loadAppStreamConfig,
ModeType,
ProtocolType,
ReturnCode,
setProcessVariables,
setupFolders,
verifyEnvVariables
} from './utils'
import { getEnvCSPDirectives } from './utils/parseHelmetConfig'
import {
configureCors,
configureExpressSession,
configureLogger,
configureSecurity
} from './app-modules'
dotenv.config()
@@ -34,99 +30,20 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
const app = express()
app.use(cookieParser())
const { PROTOCOL } = process.env
const {
MODE,
CORS,
WHITELIST,
PROTOCOL,
HELMET_CSP_CONFIG_PATH,
HELMET_COEP,
LOG_FORMAT_MORGAN
} = process.env
app.use(morgan(LOG_FORMAT_MORGAN as string))
export const cookieOptions = {
export const cookieOptions: CookieOptions = {
secure: PROTOCOL === ProtocolType.HTTPS,
httpOnly: true,
sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
HELMET_CSP_CONFIG_PATH
)
if (PROTOCOL === ProtocolType.HTTP)
cspConfigJson['upgrade-insecure-requests'] = null
/***********************************
* CSRF Protection *
***********************************/
export const csrfProtection = csrf({ cookie: cookieOptions })
/***********************************
* Handle security and origin *
***********************************/
app.use(
helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
...cspConfigJson
}
},
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
})
)
/***********************************
* Enabling CORS *
***********************************/
if (CORS === CorsType.ENABLED) {
const whiteList: string[] = []
WHITELIST?.split(' ')
?.filter((url) => !!url)
.forEach((url) => {
if (url.startsWith('http'))
// removing trailing slash of URLs listing for CORS
whiteList.push(url.replace(/\/$/, ''))
})
console.log('All CORS Requests are enabled for:', whiteList)
app.use(cors({ credentials: true, origin: whiteList }))
}
/***********************************
* DB Connection & *
* Express Sessions *
* With Mongo Store *
***********************************/
if (MODE === ModeType.Server) {
let store: MongoStore | undefined
// NOTE: when exporting app.js as agent for supertest
// we should exclude connecting to the real database
if (process.env.NODE_ENV !== 'test') {
const clientPromise = connectDB().then((conn) => conn!.getClient() as any)
store = MongoStore.create({ clientPromise, collectionName: 'sessions' })
}
app.use(
session({
secret: process.env.SESSION_SECRET as string,
saveUninitialized: false, // don't create session until something stored
resave: false, //don't save session if unmodified
store,
cookie: cookieOptions
})
)
}
app.use(express.json({ limit: '100mb' }))
app.use(express.static(path.join(__dirname, '../public')))
const onError: ErrorRequestHandler = (err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN')
return res.status(400).send('Invalid CSRF token!')
@@ -136,6 +53,30 @@ const onError: ErrorRequestHandler = (err, req, res, next) => {
}
export default setProcessVariables().then(async () => {
app.use(cookieParser())
configureLogger(app)
/***********************************
* Handle security and origin *
***********************************/
configureSecurity(app)
/***********************************
* Enabling CORS *
***********************************/
configureCors(app)
/***********************************
* DB Connection & *
* Express Sessions *
* With Mongo Store *
***********************************/
configureExpressSession(app)
app.use(express.json({ limit: '100mb' }))
app.use(express.static(path.join(__dirname, '../public')))
await setupFolders()
await copySASjsCore()

View File

@@ -129,8 +129,8 @@ const verifyAuthCode = async (
clientId: string,
code: string
): Promise<InfoJWT | undefined> => {
return new Promise((resolve, reject) => {
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => {
return new Promise((resolve) => {
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
if (err) return resolve(undefined)
const clientInfo: InfoJWT = {

View File

@@ -22,6 +22,7 @@ import {
moveFile,
createFolder,
deleteFile as deleteFileOnSystem,
deleteFolder as deleteFolderOnSystem,
folderExists,
listFilesInFolder,
listSubFoldersInFolder,
@@ -58,11 +59,32 @@ interface GetFileTreeResponse {
tree: TreeNode
}
interface UpdateFileResponse {
interface FileFolderResponse {
status: string
message?: string
}
interface AddFolderPayload {
/**
* Location of folder
* @example "/Public/someFolder"
*/
folderPath: string
}
interface RenamePayload {
/**
* Old path of file/folder
* @example "/Public/someFolder"
*/
oldPath: string
/**
* New path of file/folder
* @example "/Public/newFolder"
*/
newPath: string
}
const fileTreeExample = getTreeExample()
const successDeployResponse: DeployResponse = {
@@ -143,7 +165,7 @@ export class DriveController {
/**
*
* @summary Delete file from SASjs Drive
* @query _filePath Location of SAS program
* @query _filePath Location of file
* @example _filePath "/Public/somefolder/some.file"
*/
@Delete('/file')
@@ -151,20 +173,31 @@ export class DriveController {
return deleteFile(_filePath)
}
/**
*
* @summary Delete folder from SASjs Drive
* @query _folderPath Location of folder
* @example _folderPath "/Public/somefolder/"
*/
@Delete('/folder')
public async deleteFolder(@Query() _folderPath: string) {
return deleteFolder(_folderPath)
}
/**
* It's optional to either provide `_filePath` in url as query parameter
* Or provide `filePath` in body as form field.
* But it's required to provide else API will respond with Bad Request.
*
* @summary Create a file in SASjs Drive
* @param _filePath Location of SAS program
* @param _filePath Location of file
* @example _filePath "/Public/somefolder/some.file.sas"
*
*/
@Example<UpdateFileResponse>({
@Example<FileFolderResponse>({
status: 'success'
})
@Response<UpdateFileResponse>(403, 'File already exists', {
@Response<FileFolderResponse>(403, 'File already exists', {
status: 'failure',
message: 'File request failed.'
})
@@ -173,10 +206,28 @@ export class DriveController {
@UploadedFile() file: Express.Multer.File,
@Query() _filePath?: string,
@FormField() filePath?: string
): Promise<UpdateFileResponse> {
): Promise<FileFolderResponse> {
return saveFile((_filePath ?? filePath)!, file)
}
/**
* @summary Create an empty folder in SASjs Drive
*
*/
@Example<FileFolderResponse>({
status: 'success'
})
@Response<FileFolderResponse>(409, 'Folder already exists', {
status: 'failure',
message: 'Add folder request failed.'
})
@Post('/folder')
public async addFolder(
@Body() body: AddFolderPayload
): Promise<FileFolderResponse> {
return addFolder(body.folderPath)
}
/**
* It's optional to either provide `_filePath` in url as query parameter
* Or provide `filePath` in body as form field.
@@ -187,10 +238,10 @@ export class DriveController {
* @example _filePath "/Public/somefolder/some.file.sas"
*
*/
@Example<UpdateFileResponse>({
@Example<FileFolderResponse>({
status: 'success'
})
@Response<UpdateFileResponse>(403, `File doesn't exist`, {
@Response<FileFolderResponse>(403, `File doesn't exist`, {
status: 'failure',
message: 'File request failed.'
})
@@ -199,10 +250,28 @@ export class DriveController {
@UploadedFile() file: Express.Multer.File,
@Query() _filePath?: string,
@FormField() filePath?: string
): Promise<UpdateFileResponse> {
): Promise<FileFolderResponse> {
return updateFile((_filePath ?? filePath)!, file)
}
/**
* @summary Renames a file/folder in SASjs Drive
*
*/
@Example<FileFolderResponse>({
status: 'success'
})
@Response<FileFolderResponse>(409, 'Folder already exists', {
status: 'failure',
message: 'rename request failed.'
})
@Post('/rename')
public async rename(
@Body() body: RenamePayload
): Promise<FileFolderResponse> {
return rename(body.oldPath, body.newPath)
}
/**
* @summary Fetch file tree within SASjs Drive.
*
@@ -249,20 +318,26 @@ const getFile = async (req: express.Request, filePath: string) => {
.join(getFilesFolder(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
throw new Error('Cannot get file outside drive.')
}
if (!filePathFull.includes(driveFilesPath))
throw {
code: 400,
status: 'Bad Request',
message: `Can't get file outside drive.`
}
if (!(await fileExists(filePathFull))) {
throw new Error("File doesn't exist.")
}
if (!(await fileExists(filePathFull)))
throw {
code: 404,
status: 'Not Found',
message: `File doesn't exist.`
}
const extension = path.extname(filePathFull).toLowerCase()
if (extension === '.sas') {
req.res?.setHeader('Content-type', 'text/plain')
}
req.res?.sendFile(path.resolve(filePathFull))
req.res?.sendFile(path.resolve(filePathFull), { dotfiles: 'allow' })
}
const getFolder = async (folderPath?: string) => {
@@ -273,17 +348,26 @@ const getFolder = async (folderPath?: string) => {
.join(getFilesFolder(), folderPath)
.replace(new RegExp('/', 'g'), path.sep)
if (!folderPathFull.includes(driveFilesPath)) {
throw new Error('Cannot get folder outside drive.')
}
if (!folderPathFull.includes(driveFilesPath))
throw {
code: 400,
status: 'Bad Request',
message: `Can't get folder outside drive.`
}
if (!(await folderExists(folderPathFull))) {
throw new Error("Folder doesn't exist.")
}
if (!(await folderExists(folderPathFull)))
throw {
code: 404,
status: 'Not Found',
message: `Folder doesn't exist.`
}
if (!(await isFolder(folderPathFull))) {
throw new Error('Not a Folder.')
}
if (!(await isFolder(folderPathFull)))
throw {
code: 400,
status: 'Bad Request',
message: 'Not a Folder.'
}
const files: string[] = await listFilesInFolder(folderPathFull)
const folders: string[] = await listSubFoldersInFolder(folderPathFull)
@@ -302,19 +386,51 @@ const deleteFile = async (filePath: string) => {
.join(getFilesFolder(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
throw new Error('Cannot delete file outside drive.')
}
if (!filePathFull.includes(driveFilesPath))
throw {
code: 400,
status: 'Bad Request',
message: `Can't delete file outside drive.`
}
if (!(await fileExists(filePathFull))) {
throw new Error('File does not exist.')
}
if (!(await fileExists(filePathFull)))
throw {
code: 404,
status: 'Not Found',
message: `File doesn't exist.`
}
await deleteFileOnSystem(filePathFull)
return { status: 'success' }
}
const deleteFolder = async (folderPath: string) => {
const driveFolderPath = getFilesFolder()
const folderPathFull = path
.join(getFilesFolder(), folderPath)
.replace(new RegExp('/', 'g'), path.sep)
if (!folderPathFull.includes(driveFolderPath))
throw {
code: 400,
status: 'Bad Request',
message: `Can't delete folder outside drive.`
}
if (!(await folderExists(folderPathFull)))
throw {
code: 404,
status: 'Not Found',
message: `Folder doesn't exist.`
}
await deleteFolderOnSystem(folderPathFull)
return { status: 'success' }
}
const saveFile = async (
filePath: string,
multerFile: Express.Multer.File
@@ -325,13 +441,19 @@ const saveFile = async (
.join(driveFilesPath, filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
throw new Error('Cannot put file outside drive.')
}
if (!filePathFull.includes(driveFilesPath))
throw {
code: 400,
status: 'Bad Request',
message: `Can't put file outside drive.`
}
if (await fileExists(filePathFull)) {
throw new Error('File already exists.')
}
if (await fileExists(filePathFull))
throw {
code: 409,
status: 'Conflict',
message: 'File already exists.'
}
const folderPath = path.dirname(filePathFull)
await createFolder(folderPath)
@@ -340,6 +462,88 @@ const saveFile = async (
return { status: 'success' }
}
const addFolder = async (folderPath: string): Promise<FileFolderResponse> => {
const drivePath = getFilesFolder()
const folderPathFull = path
.join(drivePath, folderPath)
.replace(new RegExp('/', 'g'), path.sep)
if (!folderPathFull.includes(drivePath))
throw {
code: 400,
status: 'Bad Request',
message: `Can't put folder outside drive.`
}
if (await folderExists(folderPathFull))
throw {
code: 409,
status: 'Conflict',
message: 'Folder already exists.'
}
await createFolder(folderPathFull)
return { status: 'success' }
}
const rename = async (
oldPath: string,
newPath: string
): Promise<FileFolderResponse> => {
const drivePath = getFilesFolder()
const oldPathFull = path
.join(drivePath, oldPath)
.replace(new RegExp('/', 'g'), path.sep)
const newPathFull = path
.join(drivePath, newPath)
.replace(new RegExp('/', 'g'), path.sep)
if (!oldPathFull.includes(drivePath))
throw {
code: 400,
status: 'Bad Request',
message: `Old path can't be outside of drive.`
}
if (!newPathFull.includes(drivePath))
throw {
code: 400,
status: 'Bad Request',
message: `New path can't be outside of drive.`
}
if (await isFolder(oldPathFull)) {
if (await folderExists(newPathFull))
throw {
code: 409,
status: 'Conflict',
message: 'Folder with new name already exists.'
}
else moveFile(oldPathFull, newPathFull)
return { status: 'success' }
} else if (await fileExists(oldPathFull)) {
if (await fileExists(newPathFull))
throw {
code: 409,
status: 'Conflict',
message: 'File with new name already exists.'
}
else moveFile(oldPathFull, newPathFull)
return { status: 'success' }
}
throw {
code: 404,
status: 'Not Found',
message: 'No file/folder found for provided path.'
}
}
const updateFile = async (
filePath: string,
multerFile: Express.Multer.File
@@ -350,13 +554,19 @@ const updateFile = async (
.join(driveFilesPath, filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
throw new Error('Cannot modify file outside drive.')
}
if (!filePathFull.includes(driveFilesPath))
throw {
code: 400,
status: 'Bad Request',
message: `Can't modify file outside drive.`
}
if (!(await fileExists(filePathFull))) {
throw new Error(`File doesn't exist.`)
}
if (!(await fileExists(filePathFull)))
throw {
code: 404,
status: 'Not Found',
message: `File doesn't exist.`
}
await moveFile(multerFile.path, filePathFull)

View File

@@ -10,7 +10,7 @@ import {
Body
} from 'tsoa'
import Group, { GroupPayload } from '../model/Group'
import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group'
import User from '../model/User'
import { UserResponse } from './user'
@@ -20,7 +20,7 @@ export interface GroupResponse {
description: string
}
interface GroupDetailsResponse {
export interface GroupDetailsResponse {
groupId: number
name: string
description: string
@@ -198,7 +198,7 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
'groupId name description isActive users -_id'
).populate(
'users',
'id username displayName -_id'
'id username displayName isAdmin -_id'
)) as unknown as GroupDetailsResponse
if (!group)
throw {
@@ -241,6 +241,13 @@ const updateUsersListInGroup = async (
message: 'Group not found.'
}
if (group.name === PUBLIC_GROUP_NAME)
throw {
code: 400,
status: 'Bad Request',
message: `Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
}
const user = await User.findOne({ id: userId })
if (!user)
throw {
@@ -249,9 +256,10 @@ const updateUsersListInGroup = async (
message: 'User not found.'
}
const updatedGroup = (action === 'addUser'
? await group.addUser(user._id)
: await group.removeUser(user._id)) as unknown as GroupDetailsResponse
const updatedGroup =
action === 'addUser'
? await group.addUser(user)
: await group.removeUser(user)
if (!updatedGroup)
throw {
@@ -260,9 +268,6 @@ const updateUsersListInGroup = async (
message: 'Unable to update group.'
}
if (action === 'addUser') user.addGroup(group._id)
else user.removeGroup(group._id)
return {
groupId: updatedGroup.groupId,
name: updatedGroup.name,

View File

@@ -4,6 +4,7 @@ export * from './code'
export * from './drive'
export * from './group'
export * from './info'
export * from './permission'
export * from './session'
export * from './stp'
export * from './user'

View File

@@ -1,4 +1,8 @@
import { Route, Tags, Example, Get } from 'tsoa'
import { getAuthorizedRoutes } from '../utils'
export interface AuthorizedRoutesResponse {
paths: string[]
}
export interface InfoResponse {
mode: string
@@ -36,4 +40,19 @@ export class InfoController {
}
return response
}
/**
* @summary Get the list of available routes to which permissions can be applied. Used to populate the dialog in the URI Permissions feature.
*
*/
@Example<AuthorizedRoutesResponse>({
paths: ['/AppStream', '/SASjsApi/stp/execute']
})
@Get('/authorizedRoutes')
public authorizedRoutes(): AuthorizedRoutesResponse {
const response = {
paths: getAuthorizedRoutes()
}
return response
}
}

View File

@@ -143,6 +143,7 @@ export class ExecutionController {
name: 'files',
relativePath: '',
absolutePath: getFilesFolder(),
isFolder: true,
children: []
}
@@ -152,15 +153,22 @@ export class ExecutionController {
const currentNode = stack.pop()
if (currentNode) {
currentNode.isFolder = fs
.statSync(currentNode.absolutePath)
.isDirectory()
const children = fs.readdirSync(currentNode.absolutePath)
for (let child of children) {
const absoluteChildPath = `${currentNode.absolutePath}/${child}`
const absoluteChildPath = path.join(currentNode.absolutePath, child)
// relative path will only be used in frontend component
// so, no need to convert '/' to platform specific separator
const relativeChildPath = `${currentNode.relativePath}/${child}`
const childNode: TreeNode = {
name: child,
relativePath: relativeChildPath,
absolutePath: absoluteChildPath,
isFolder: false,
children: []
}
currentNode.children.push(childNode)

View File

@@ -101,8 +101,11 @@ ${autoExecContent}`
session.path,
'-AUTOEXEC',
autoExecPath,
isWindows() ? '-nosplash' : '',
isWindows() ? '-icon' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '',
isWindows() ? '-nologo' : ''
])
.then(() => {

View File

@@ -23,7 +23,9 @@ let _webout = '';
const weboutPath = '${
isWindows() ? weboutPath.replace(/\\/g, '\\\\') : weboutPath
}';
const _sasjs_tokenfile = '${tokenFile}';
const _sasjs_tokenfile = '${
isWindows() ? tokenFile.replace(/\\/g, '\\\\') : tokenFile
}';
const _sasjs_username = '${preProgramVariables?.username}';
const _sasjs_userid = '${preProgramVariables?.userId}';
const _sasjs_displayname = '${preProgramVariables?.displayName}';

View File

@@ -0,0 +1,368 @@
import express from 'express'
import {
Security,
Route,
Tags,
Path,
Example,
Get,
Post,
Patch,
Delete,
Body,
Request
} from 'tsoa'
import Permission from '../model/Permission'
import User from '../model/User'
import Group from '../model/Group'
import { UserResponse } from './user'
import { GroupDetailsResponse } from './group'
export enum PermissionType {
route = 'Route'
}
export enum PrincipalType {
user = 'user',
group = 'group'
}
export enum PermissionSettingForRoute {
grant = 'Grant',
deny = 'Deny'
}
interface RegisterPermissionPayload {
/**
* Name of affected resource
* @example "/SASjsApi/code/execute"
*/
path: string
/**
* Type of affected resource
* @example "Route"
*/
type: PermissionType
/**
* The indication of whether (and to what extent) access is provided
* @example "Grant"
*/
setting: PermissionSettingForRoute
/**
* Indicates the type of principal
* @example "user"
*/
principalType: PrincipalType
/**
* The id of user or group to which a rule is assigned.
* @example 123
*/
principalId: number
}
interface UpdatePermissionPayload {
/**
* The indication of whether (and to what extent) access is provided
* @example "Grant"
*/
setting: PermissionSettingForRoute
}
export interface PermissionDetailsResponse {
permissionId: number
path: string
type: string
setting: string
user?: UserResponse
group?: GroupDetailsResponse
}
@Security('bearerAuth')
@Route('SASjsApi/permission')
@Tags('Permission')
export class PermissionController {
/**
* Get the list of permission rules applicable the authenticated user.
* If the user is an admin, all rules are returned.
*
* @summary Get the list of permission rules. If the user is admin, all rules are returned.
*
*/
@Example<PermissionDetailsResponse[]>([
{
permissionId: 123,
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
}
},
{
permissionId: 124,
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
group: {
groupId: 1,
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
users: []
}
}
])
@Get('/')
public async getAllPermissions(
@Request() request: express.Request
): Promise<PermissionDetailsResponse[]> {
return getAllPermissions(request)
}
/**
* @summary Create a new permission. Admin only.
*
*/
@Example<PermissionDetailsResponse>({
permissionId: 123,
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
}
})
@Post('/')
public async createPermission(
@Body() body: RegisterPermissionPayload
): Promise<PermissionDetailsResponse> {
return createPermission(body)
}
/**
* @summary Update permission setting. Admin only
* @param permissionId The permission's identifier
* @example permissionId 1234
*/
@Example<PermissionDetailsResponse>({
permissionId: 123,
path: '/SASjsApi/code/execute',
type: 'Route',
setting: 'Grant',
user: {
id: 1,
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
}
})
@Patch('{permissionId}')
public async updatePermission(
@Path() permissionId: number,
@Body() body: UpdatePermissionPayload
): Promise<PermissionDetailsResponse> {
return updatePermission(permissionId, body)
}
/**
* @summary Delete a permission. Admin only.
* @param permissionId The user's identifier
* @example permissionId 1234
*/
@Delete('{permissionId}')
public async deletePermission(@Path() permissionId: number) {
return deletePermission(permissionId)
}
}
const getAllPermissions = async (
req: express.Request
): Promise<PermissionDetailsResponse[]> => {
const { user } = req
if (user?.isAdmin) return await Permission.get({})
else {
const permissions: PermissionDetailsResponse[] = []
const dbUser = await User.findOne({ id: user?.userId })
if (!dbUser)
throw {
code: 404,
status: 'Not Found',
message: 'User not found.'
}
permissions.push(...(await Permission.get({ user: dbUser._id })))
for (const group of dbUser.groups) {
permissions.push(...(await Permission.get({ group })))
}
return permissions
}
}
const createPermission = async ({
path,
type,
setting,
principalType,
principalId
}: RegisterPermissionPayload): Promise<PermissionDetailsResponse> => {
const permission = new Permission({
path,
type,
setting
})
let user: UserResponse | undefined
let group: GroupDetailsResponse | undefined
switch (principalType) {
case PrincipalType.user: {
const userInDB = await User.findOne({ id: principalId })
if (!userInDB)
throw {
code: 404,
status: 'Not Found',
message: 'User not found.'
}
if (userInDB.isAdmin)
throw {
code: 400,
status: 'Bad Request',
message: 'Can not add permission for admin user.'
}
const alreadyExists = await Permission.findOne({
path,
type,
user: userInDB._id
})
if (alreadyExists)
throw {
code: 409,
status: 'Conflict',
message:
'Permission already exists with provided Path, Type and User.'
}
permission.user = userInDB._id
user = {
id: userInDB.id,
username: userInDB.username,
displayName: userInDB.displayName,
isAdmin: userInDB.isAdmin
}
break
}
case PrincipalType.group: {
const groupInDB = await Group.findOne({ groupId: principalId })
if (!groupInDB)
throw {
code: 404,
status: 'Not Found',
message: 'Group not found.'
}
const alreadyExists = await Permission.findOne({
path,
type,
group: groupInDB._id
})
if (alreadyExists)
throw {
code: 409,
status: 'Conflict',
message:
'Permission already exists with provided Path, Type and Group.'
}
permission.group = groupInDB._id
group = {
groupId: groupInDB.groupId,
name: groupInDB.name,
description: groupInDB.description,
isActive: groupInDB.isActive,
users: groupInDB.populate({
path: 'users',
select: 'id username displayName isAdmin -_id',
options: { limit: 15 }
}) as unknown as UserResponse[]
}
break
}
default:
throw {
code: 400,
status: 'Bad Request',
message: 'Invalid principal type. Valid types are user or group.'
}
}
const savedPermission = await permission.save()
return {
permissionId: savedPermission.permissionId,
path: savedPermission.path,
type: savedPermission.type,
setting: savedPermission.setting,
user,
group
}
}
const updatePermission = async (
id: number,
data: UpdatePermissionPayload
): Promise<PermissionDetailsResponse> => {
const { setting } = data
const updatedPermission = (await Permission.findOneAndUpdate(
{ permissionId: id },
{ setting },
{ new: true }
)
.select({
_id: 0,
permissionId: 1,
path: 1,
type: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.populate({
path: 'group',
select: 'groupId name description -_id'
})) as unknown as PermissionDetailsResponse
if (!updatedPermission)
throw {
code: 404,
status: 'Not Found',
message: 'Permission not found.'
}
return updatedPermission
}
const deletePermission = async (id: number) => {
const permission = await Permission.findOne({ permissionId: id })
if (!permission)
throw {
code: 404,
status: 'Not Found',
message: 'Permission not found.'
}
await Permission.deleteOne({ permissionId: id })
}

View File

@@ -13,7 +13,8 @@ export class SessionController {
@Example<UserResponse>({
id: 123,
username: 'johnusername',
displayName: 'John'
displayName: 'John',
isAdmin: false
})
@Get('/')
public async session(
@@ -26,5 +27,6 @@ export class SessionController {
const session = (req: express.Request) => ({
id: req.user!.userId,
username: req.user!.username,
displayName: req.user!.displayName
displayName: req.user!.displayName,
isAdmin: req.user!.isAdmin
})

View File

@@ -24,9 +24,10 @@ export interface UserResponse {
id: number
username: string
displayName: string
isAdmin: boolean
}
interface UserDetailsResponse {
export interface UserDetailsResponse {
id: number
displayName: string
username: string
@@ -48,12 +49,14 @@ export class UserController {
{
id: 123,
username: 'johnusername',
displayName: 'John'
displayName: 'John',
isAdmin: false
},
{
id: 456,
username: 'starkusername',
displayName: 'Stark'
displayName: 'Stark',
isAdmin: true
}
])
@Get('/')
@@ -200,7 +203,7 @@ export class UserController {
const getAllUsers = async (): Promise<UserResponse[]> =>
await User.find({})
.select({ _id: 0, id: 1, username: 1, displayName: 1 })
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
.exec()
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {

View File

@@ -99,7 +99,8 @@ const login = async (
user: {
id: user.id,
username: user.username,
displayName: user.displayName
displayName: user.displayName,
isAdmin: user.isAdmin
}
}
}

View File

@@ -1,8 +1,16 @@
import { RequestHandler, Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { csrfProtection } from '../app'
import { fetchLatestAutoExec, ModeType, verifyTokenInDB } from '../utils'
import {
fetchLatestAutoExec,
ModeType,
verifyTokenInDB,
isAuthorizingRoute,
isPublicRoute,
publicUser
} from '../utils'
import { desktopUser } from './desktop'
import { authorize } from './authorize'
export const authenticateAccessToken: RequestHandler = async (
req,
@@ -15,6 +23,10 @@ export const authenticateAccessToken: RequestHandler = async (
return next()
}
const nextFunction = isAuthorizingRoute(req)
? () => authorize(req, res, next)
: next
// if request is coming from web and has valid session
// it can be validated.
if (req.session?.loggedIn) {
@@ -24,33 +36,37 @@ export const authenticateAccessToken: RequestHandler = async (
if (user) {
if (user.isActive) {
req.user = user
return csrfProtection(req, res, next)
return csrfProtection(req, res, nextFunction)
} else return res.sendStatus(401)
}
}
return res.sendStatus(401)
}
authenticateToken(
await authenticateToken(
req,
res,
next,
process.env.ACCESS_TOKEN_SECRET as string,
nextFunction,
process.secrets.ACCESS_TOKEN_SECRET,
'accessToken'
)
}
export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
authenticateToken(
export const authenticateRefreshToken: RequestHandler = async (
req,
res,
next
) => {
await authenticateToken(
req,
res,
next,
process.env.REFRESH_TOKEN_SECRET as string,
process.secrets.REFRESH_TOKEN_SECRET,
'refreshToken'
)
}
const authenticateToken = (
const authenticateToken = async (
req: Request,
res: Response,
next: NextFunction,
@@ -58,7 +74,7 @@ const authenticateToken = (
tokenType: 'accessToken' | 'refreshToken'
) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
if (MODE === ModeType.Desktop) {
req.user = {
userId: 1234,
clientId: 'desktopModeClientId',
@@ -73,12 +89,12 @@ const authenticateToken = (
const authHeader = req.headers['authorization']
const token = authHeader?.split(' ')[1]
if (!token) return res.sendStatus(401)
jwt.verify(token, key, async (err: any, data: any) => {
if (err) return res.sendStatus(401)
try {
if (!token) throw 'Unauthorized'
const data: any = jwt.verify(token, key)
// verify this valid token's entry in DB
const user = await verifyTokenInDB(
data?.userId,
data?.clientId,
@@ -91,8 +107,16 @@ const authenticateToken = (
req.user = user
if (tokenType === 'accessToken') req.accessToken = token
return next()
} else return res.sendStatus(401)
} else throw 'Unauthorized'
}
return res.sendStatus(401)
})
throw 'Unauthorized'
} catch (error) {
if (await isPublicRoute(req)) {
req.user = publicUser
return next()
}
res.sendStatus(401)
}
}

View File

@@ -0,0 +1,51 @@
import { RequestHandler } from 'express'
import User from '../model/User'
import Permission from '../model/Permission'
import {
PermissionSettingForRoute,
PermissionType
} from '../controllers/permission'
import { getPath, isPublicRoute } from '../utils'
export const authorize: RequestHandler = async (req, res, next) => {
const { user } = req
if (!user) {
return res.sendStatus(401)
}
// no need to check for permissions when user is admin
if (user.isAdmin) return next()
// no need to check for permissions when route is Public
if (await isPublicRoute(req)) return next()
const dbUser = await User.findOne({ id: user.userId })
if (!dbUser) return res.sendStatus(401)
const path = getPath(req)
// find permission w.r.t user
const permission = await Permission.findOne({
path,
type: PermissionType.route,
user: dbUser._id
})
if (permission) {
if (permission.setting === PermissionSettingForRoute.grant) return next()
else return res.sendStatus(401)
}
// find permission w.r.t user's groups
for (const group of dbUser.groups) {
const groupPermission = await Permission.findOne({
path,
type: PermissionType.route,
group
})
if (groupPermission?.setting === PermissionSettingForRoute.grant)
return next()
}
return res.sendStatus(401)
}

View File

@@ -2,3 +2,4 @@ export * from './authenticateToken'
export * from './desktop'
export * from './verifyAdmin'
export * from './verifyAdminIfNeeded'
export * from './authorize'

View File

@@ -1,8 +1,9 @@
import { RequestHandler } from 'express'
import { ModeType } from '../utils'
export const verifyAdmin: RequestHandler = (req, res, next) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') return next()
if (MODE === ModeType.Desktop) return next()
const { user } = req
if (!user?.isAdmin) return res.status(401).send('Admin account required')

View File

@@ -0,0 +1,45 @@
import mongoose, { Schema } from 'mongoose'
export interface ConfigurationType {
/**
* SecretOrPrivateKey to sign Access Token
* @example "someRandomCryptoString"
*/
ACCESS_TOKEN_SECRET: string
/**
* SecretOrPrivateKey to sign Refresh Token
* @example "someRandomCryptoString"
*/
REFRESH_TOKEN_SECRET: string
/**
* SecretOrPrivateKey to sign Auth Code
* @example "someRandomCryptoString"
*/
AUTH_CODE_SECRET: string
/**
* Secret used to sign the session cookie
* @example "someRandomCryptoString"
*/
SESSION_SECRET: string
}
const ConfigurationSchema = new Schema<ConfigurationType>({
ACCESS_TOKEN_SECRET: {
type: String,
required: true
},
REFRESH_TOKEN_SECRET: {
type: String,
required: true
},
AUTH_CODE_SECRET: {
type: String,
required: true
},
SESSION_SECRET: {
type: String,
required: true
}
})
export default mongoose.model('Configuration', ConfigurationSchema)

View File

@@ -1,7 +1,10 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose'
import User from './User'
import { GroupDetailsResponse } from '../controllers'
import User, { IUser } from './User'
const AutoIncrement = require('mongoose-sequence')(mongoose)
export const PUBLIC_GROUP_NAME = 'Public'
export interface GroupPayload {
/**
* Name of the group
@@ -27,8 +30,9 @@ interface IGroupDocument extends GroupPayload, Document {
}
interface IGroup extends IGroupDocument {
addUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
removeUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
addUser(user: IUser): Promise<GroupDetailsResponse>
removeUser(user: IUser): Promise<GroupDetailsResponse>
hasUser(user: IUser): boolean
}
interface IGroupModel extends Model<IGroup> {}
@@ -70,28 +74,31 @@ groupSchema.pre('remove', async function () {
})
// Instance Methods
groupSchema.method(
'addUser',
async function (userObjectId: Schema.Types.ObjectId) {
const userIdIndex = this.users.indexOf(userObjectId)
if (userIdIndex === -1) {
this.users.push(userObjectId)
}
this.markModified('users')
return this.save()
groupSchema.method('addUser', async function (user: IUser) {
const userObjectId = user._id
const userIdIndex = this.users.indexOf(userObjectId)
if (userIdIndex === -1) {
this.users.push(userObjectId)
user.addGroup(this._id)
}
)
groupSchema.method(
'removeUser',
async function (userObjectId: Schema.Types.ObjectId) {
const userIdIndex = this.users.indexOf(userObjectId)
if (userIdIndex > -1) {
this.users.splice(userIdIndex, 1)
}
this.markModified('users')
return this.save()
this.markModified('users')
return this.save()
})
groupSchema.method('removeUser', async function (user: IUser) {
const userObjectId = user._id
const userIdIndex = this.users.indexOf(userObjectId)
if (userIdIndex > -1) {
this.users.splice(userIdIndex, 1)
user.removeGroup(this._id)
}
)
this.markModified('users')
return this.save()
})
groupSchema.method('hasUser', function (user: IUser) {
const userObjectId = user._id
const userIdIndex = this.users.indexOf(userObjectId)
return userIdIndex > -1
})
export const Group: IGroupModel = model<IGroup, IGroupModel>(
'Group',

View File

@@ -0,0 +1,73 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose'
const AutoIncrement = require('mongoose-sequence')(mongoose)
import { PermissionDetailsResponse } from '../controllers'
interface GetPermissionBy {
user?: Schema.Types.ObjectId
group?: Schema.Types.ObjectId
}
interface IPermissionDocument extends Document {
path: string
type: string
setting: string
permissionId: number
user: Schema.Types.ObjectId
group: Schema.Types.ObjectId
}
interface IPermission extends IPermissionDocument {}
interface IPermissionModel extends Model<IPermission> {
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
}
const permissionSchema = new Schema<IPermissionDocument>({
path: {
type: String,
required: true
},
type: {
type: String,
required: true
},
setting: {
type: String,
required: true
},
user: { type: Schema.Types.ObjectId, ref: 'User' },
group: { type: Schema.Types.ObjectId, ref: 'Group' }
})
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
// Static Methods
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
PermissionDetailsResponse[]
> {
return (await this.find(getBy)
.select({
_id: 0,
permissionId: 1,
path: 1,
type: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.populate({
path: 'group',
select: 'groupId name description -_id',
populate: {
path: 'users',
select: 'id username displayName isAdmin -_id',
options: { limit: 15 }
}
})) as unknown as PermissionDetailsResponse[]
})
export const Permission: IPermissionModel = model<
IPermission,
IPermissionModel
>('Permission', permissionSchema)
export default Permission

View File

@@ -35,6 +35,7 @@ export interface UserPayload {
}
interface IUserDocument extends UserPayload, Document {
_id: Schema.Types.ObjectId
id: number
isAdmin: boolean
isActive: boolean
@@ -43,7 +44,7 @@ interface IUserDocument extends UserPayload, Document {
tokens: [{ [key: string]: string }]
}
interface IUser extends IUserDocument {
export interface IUser extends IUserDocument {
comparePassword(password: string): boolean
addGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
removeGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>

View File

@@ -11,8 +11,10 @@ import {
extractName,
fileBodyValidation,
fileParamValidation,
folderBodyValidation,
folderParamValidation,
isZipFile
isZipFile,
renameBodyValidation
} from '../../utils'
const controller = new DriveController()
@@ -119,7 +121,11 @@ driveRouter.get('/file', async (req, res) => {
try {
await controller.getFile(req, query._filePath)
} catch (err: any) {
res.status(403).send(err.toString())
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
@@ -132,7 +138,11 @@ driveRouter.get('/folder', async (req, res) => {
const response = await controller.getFolder(query._folderPath)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
@@ -145,7 +155,28 @@ driveRouter.delete('/file', async (req, res) => {
const response = await controller.deleteFile(query._filePath)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
driveRouter.delete('/folder', async (req, res) => {
const { error: errQ, value: query } = folderParamValidation(req.query, true)
if (errQ) return res.status(400).send(errQ.details[0].message)
try {
const response = await controller.deleteFolder(query._folderPath)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
@@ -172,11 +203,33 @@ driveRouter.post(
res.send(response)
} catch (err: any) {
await deleteFile(req.file.path)
res.status(403).send(err.toString())
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
}
)
driveRouter.post('/folder', async (req, res) => {
const { error, value: body } = folderBodyValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.addFolder(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
driveRouter.patch(
'/file',
(...arg) => multerSingle('file', arg),
@@ -200,11 +253,33 @@ driveRouter.patch(
res.send(response)
} catch (err: any) {
await deleteFile(req.file.path)
res.status(403).send(err.toString())
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
}
)
driveRouter.post('/rename', async (req, res) => {
const { error, value: body } = renameBodyValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.rename(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
driveRouter.get('/fileTree', async (req, res) => {
try {
const response = await controller.getFileTree()

View File

@@ -17,6 +17,7 @@ import groupRouter from './group'
import clientRouter from './client'
import authRouter from './auth'
import sessionRouter from './session'
import permissionRouter from './permission'
const router = express.Router()
@@ -35,6 +36,12 @@ router.use('/group', desktopRestrict, groupRouter)
router.use('/stp', authenticateAccessToken, stpRouter)
router.use('/code', authenticateAccessToken, codeRouter)
router.use('/user', desktopRestrict, userRouter)
router.use(
'/permission',
desktopRestrict,
authenticateAccessToken,
permissionRouter
)
router.use(
'/',

View File

@@ -13,4 +13,14 @@ infoRouter.get('/', async (req, res) => {
}
})
infoRouter.get('/authorizedRoutes', async (req, res) => {
const controller = new InfoController()
try {
const response = controller.authorizedRoutes()
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
export default infoRouter

View File

@@ -0,0 +1,69 @@
import express from 'express'
import { PermissionController } from '../../controllers/'
import { verifyAdmin } from '../../middlewares'
import {
registerPermissionValidation,
updatePermissionValidation
} from '../../utils'
const permissionRouter = express.Router()
const controller = new PermissionController()
permissionRouter.get('/', async (req, res) => {
try {
const response = await controller.getAllPermissions(req)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
permissionRouter.post('/', verifyAdmin, async (req, res) => {
const { error, value: body } = registerPermissionValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.createPermission(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
const { permissionId } = req.params
const { error, value: body } = updatePermissionValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.updatePermission(permissionId, body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
permissionRouter.delete(
'/:permissionId',
verifyAdmin,
async (req: any, res) => {
const { permissionId } = req.params
try {
await controller.deletePermission(permissionId)
res.status(200).send('Permission Deleted!')
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
}
)
export default permissionRouter

View File

@@ -29,7 +29,13 @@ jest
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import {
UserController,
PermissionController,
PermissionType,
PermissionSettingForRoute,
PrincipalType
} from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal'
import { generateAccessToken, saveTokensInDB } from '../../../utils/'
const { getFilesFolder } = fileUtilModules
@@ -43,11 +49,18 @@ const user = {
isActive: true
}
const permission = {
type: PermissionType.route,
principalType: PrincipalType.user,
setting: PermissionSettingForRoute.grant
}
describe('drive', () => {
let app: Express
let con: Mongoose
let mongoServer: MongoMemoryServer
const controller = new UserController()
const permissionController = new PermissionController()
let accessToken: string
@@ -58,11 +71,32 @@ describe('drive', () => {
con = await mongoose.connect(mongoServer.getUri())
const dbUser = await controller.createUser(user)
accessToken = generateAccessToken({
clientId,
userId: dbUser.id
accessToken = await generateAndSaveToken(dbUser.id)
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/deploy',
principalId: dbUser.id
})
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/deploy/upload',
principalId: dbUser.id
})
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/file',
principalId: dbUser.id
})
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/folder',
principalId: dbUser.id
})
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/rename',
principalId: dbUser.id
})
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
})
afterAll(async () => {
@@ -517,29 +551,29 @@ describe('drive', () => {
expect(res.body).toEqual({})
})
it('should respond with Forbidden if folder is not present', async () => {
it('should respond with Not Found if folder is not present', async () => {
const res = await request(app)
.get(getFolderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: `/my/path/code-${generateTimestamp()}` })
.expect(403)
.expect(404)
expect(res.text).toEqual(`Error: Folder doesn't exist.`)
expect(res.text).toEqual(`Folder doesn't exist.`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if folderPath outside Drive', async () => {
it('should respond with Bad Request if folderPath outside Drive', async () => {
const res = await request(app)
.get(getFolderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: '/../path/code.sas' })
.expect(403)
.expect(400)
expect(res.text).toEqual('Error: Cannot get folder outside drive.')
expect(res.text).toEqual(`Can't get folder outside drive.`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if folderPath is of a file', async () => {
it('should respond with Bad Request if folderPath is of a file', async () => {
const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas')
const filePath = '/my/path/code.sas'
@@ -550,12 +584,96 @@ describe('drive', () => {
.get(getFolderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: filePath })
.expect(403)
.expect(400)
expect(res.text).toEqual('Error: Not a Folder.')
expect(res.text).toEqual('Not a Folder.')
expect(res.body).toEqual({})
})
})
describe('post', () => {
const folderApi = '/SASjsApi/drive/folder'
const pathToDrive = fileUtilModules.getFilesFolder()
afterEach(async () => {
await deleteFolder(path.join(pathToDrive, 'post'))
})
it('should create a folder on drive', async () => {
const res = await request(app)
.post(folderApi)
.auth(accessToken, { type: 'bearer' })
.send({ folderPath: '/post/folder' })
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({
status: 'success'
})
})
it('should respond with Conflict if the folder already exists', async () => {
await createFolder(path.join(pathToDrive, '/post/folder'))
const res = await request(app)
.post(folderApi)
.auth(accessToken, { type: 'bearer' })
.send({ folderPath: '/post/folder' })
.expect(409)
expect(res.text).toEqual(`Folder already exists.`)
expect(res.statusCode).toEqual(409)
})
it('should respond with Bad Request if the folderPath is outside drive', async () => {
const res = await request(app)
.post(folderApi)
.auth(accessToken, { type: 'bearer' })
.send({ folderPath: '../sample' })
.expect(400)
expect(res.text).toEqual(`Can't put folder outside drive.`)
})
})
describe('delete', () => {
const folderApi = '/SASjsApi/drive/folder'
const pathToDrive = fileUtilModules.getFilesFolder()
it('should delete a folder on drive', async () => {
await createFolder(path.join(pathToDrive, 'delete'))
const res = await request(app)
.delete(folderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: 'delete' })
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({
status: 'success'
})
})
it('should respond with Not Found if the folder does not exists', async () => {
const res = await request(app)
.delete(folderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: 'notExists' })
.expect(404)
expect(res.text).toEqual(`Folder doesn't exist.`)
})
it('should respond with Bad Request if the folderPath is outside drive', async () => {
const res = await request(app)
.delete(folderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: '../outsideDrive' })
.expect(400)
expect(res.text).toEqual(`Can't delete folder outside drive.`)
})
})
})
describe('file', () => {
@@ -601,7 +719,7 @@ describe('drive', () => {
expect(res.body).toEqual({})
})
it('should respond with Forbidden if file is already present', async () => {
it('should respond with Conflict if file is already present', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = `/my/path/code-${generateTimestamp()}.sas`
@@ -616,13 +734,13 @@ describe('drive', () => {
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(403)
.expect(409)
expect(res.text).toEqual('Error: File already exists.')
expect(res.text).toEqual('File already exists.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if filePath outside Drive', async () => {
it('should respond with Bad Request if filePath outside Drive', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = '/../path/code.sas'
@@ -631,9 +749,9 @@ describe('drive', () => {
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(403)
.expect(400)
expect(res.text).toEqual('Error: Cannot put file outside drive.')
expect(res.text).toEqual(`Can't put file outside drive.`)
expect(res.body).toEqual({})
})
@@ -768,19 +886,19 @@ describe('drive', () => {
expect(res.body).toEqual({})
})
it('should respond with Forbidden if file is not present', async () => {
it('should respond with Not Found if file is not present', async () => {
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', `/my/path/code-3.sas`)
.attach('file', path.join(__dirname, 'files', 'sample.sas'))
.expect(403)
.expect(404)
expect(res.text).toEqual(`Error: File doesn't exist.`)
expect(res.text).toEqual(`File doesn't exist.`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if filePath outside Drive', async () => {
it('should respond with Bad Request if filePath outside Drive', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = '/../path/code.sas'
@@ -789,9 +907,9 @@ describe('drive', () => {
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(403)
.expect(400)
expect(res.text).toEqual('Error: Cannot modify file outside drive.')
expect(res.text).toEqual(`Can't modify file outside drive.`)
expect(res.body).toEqual({})
})
@@ -896,25 +1014,25 @@ describe('drive', () => {
expect(res.body).toEqual({})
})
it('should respond with Forbidden if file is not present', async () => {
it('should respond with Not Found if file is not present', async () => {
const res = await request(app)
.get('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.query({ _filePath: `/my/path/code-4.sas` })
.expect(403)
.expect(404)
expect(res.text).toEqual(`Error: File doesn't exist.`)
expect(res.text).toEqual(`File doesn't exist.`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if filePath outside Drive', async () => {
it('should respond with Bad Request if filePath outside Drive', async () => {
const res = await request(app)
.get('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.query({ _filePath: '/../path/code.sas' })
.expect(403)
.expect(400)
expect(res.text).toEqual('Error: Cannot get file outside drive.')
expect(res.text).toEqual(`Can't get file outside drive.`)
expect(res.body).toEqual({})
})
@@ -940,8 +1058,150 @@ describe('drive', () => {
})
})
})
describe('rename', () => {
const renameApi = '/SASjsApi/drive/rename'
const pathToDrive = fileUtilModules.getFilesFolder()
afterEach(async () => {
await deleteFolder(path.join(pathToDrive, 'rename'))
})
it('should rename a folder', async () => {
await createFolder(path.join(pathToDrive, 'rename', 'folder'))
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ oldPath: '/rename/folder', newPath: '/rename/renamed' })
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({
status: 'success'
})
})
it('should rename a file', async () => {
await createFile(
path.join(pathToDrive, 'rename', 'file.txt'),
'some file content'
)
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({
oldPath: '/rename/file.txt',
newPath: '/rename/renamed.txt'
})
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({
status: 'success'
})
})
it('should respond with Bad Request if the oldPath is missing', async () => {
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ newPath: 'newPath' })
.expect(400)
expect(res.text).toEqual(`\"oldPath\" is required`)
})
it('should respond with Bad Request if the newPath is missing', async () => {
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ oldPath: 'oldPath' })
.expect(400)
expect(res.text).toEqual(`\"newPath\" is required`)
})
it('should respond with Bad Request if the oldPath is outside drive', async () => {
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ oldPath: '../outside', newPath: 'renamed' })
.expect(400)
expect(res.text).toEqual(`Old path can't be outside of drive.`)
})
it('should respond with Bad Request if the newPath is outside drive', async () => {
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ oldPath: 'older', newPath: '../outside' })
.expect(400)
expect(res.text).toEqual(`New path can't be outside of drive.`)
})
it('should respond with Not Found if the folder does not exist', async () => {
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ oldPath: '/rename/not exists', newPath: '/rename/renamed' })
.expect(404)
expect(res.text).toEqual('No file/folder found for provided path.')
})
it('should respond with Conflict if the folder already exists', async () => {
await createFolder(path.join(pathToDrive, 'rename', 'folder'))
await createFolder(path.join(pathToDrive, 'rename', 'exists'))
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ oldPath: '/rename/folder', newPath: '/rename/exists' })
.expect(409)
expect(res.text).toEqual('Folder with new name already exists.')
})
it('should respond with Not Found if the file does not exist', async () => {
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ oldPath: '/rename/file.txt', newPath: '/rename/renamed.txt' })
.expect(404)
expect(res.text).toEqual('No file/folder found for provided path.')
})
it('should respond with Conflict if the file already exists', async () => {
await createFile(
path.join(pathToDrive, 'rename', 'file.txt'),
'some file content'
)
await createFile(
path.join(pathToDrive, 'rename', 'exists.txt'),
'some existing content'
)
const res = await request(app)
.post(renameApi)
.auth(accessToken, { type: 'bearer' })
.send({ oldPath: '/rename/file.txt', newPath: '/rename/exists.txt' })
.expect(409)
expect(res.text).toEqual('File with new name already exists.')
})
})
})
const getExampleService = (): ServiceMember =>
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
.members[0] as ServiceMember
const generateAndSaveToken = async (userId: number) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
})
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
return adminAccessToken
}

View File

@@ -5,6 +5,7 @@ import request from 'supertest'
import appPromise from '../../../app'
import { UserController, GroupController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { PUBLIC_GROUP_NAME } from '../../../model/Group'
const clientId = 'someclientID'
const adminUser = {
@@ -27,6 +28,12 @@ const group = {
description: 'DC group for testing purposes.'
}
const PUBLIC_GROUP = {
name: PUBLIC_GROUP_NAME,
description:
'A special group that can be used to bypass authentication for particular routes.'
}
const userController = new UserController()
const groupController = new GroupController()
@@ -535,6 +542,24 @@ describe('group', () => {
expect(res.text).toEqual('User not found.')
expect(res.body).toEqual({})
})
it('should respond with Bad Request when adding user to Public group', async () => {
const dbGroup = await groupController.createGroup(PUBLIC_GROUP)
const dbUser = await userController.createUser({
...user,
username: 'publicUser'
})
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(400)
expect(res.text).toEqual(
`Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
)
})
})
describe('RemoveUser', () => {

View File

@@ -0,0 +1,596 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import {
DriveController,
UserController,
GroupController,
PermissionController,
PrincipalType,
PermissionType,
PermissionSettingForRoute
} from '../../../controllers/'
import {
UserDetailsResponse,
PermissionDetailsResponse
} from '../../../controllers'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
const deployPayload = {
appLoc: 'string',
streamWebFolder: 'string',
fileTree: {
members: [
{
name: 'string',
type: 'folder',
members: [
'string',
{
name: 'string',
type: 'service',
code: 'string'
}
]
}
]
}
}
const clientId = 'someclientID'
const adminUser = {
displayName: 'Test Admin',
username: 'testAdminUsername',
password: '12345678',
isAdmin: true,
isActive: true
}
const user = {
displayName: 'Test User',
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true
}
const permission = {
path: '/SASjsApi/code/execute',
type: PermissionType.route,
setting: PermissionSettingForRoute.grant,
principalType: PrincipalType.user
}
const group = {
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
const userController = new UserController()
const groupController = new GroupController()
const permissionController = new PermissionController()
describe('permission', () => {
let app: Express
let con: Mongoose
let mongoServer: MongoMemoryServer
let adminAccessToken: string
let dbUser: UserDetailsResponse
beforeAll(async () => {
app = await appPromise
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
adminAccessToken = await generateSaveTokenAndCreateUser()
dbUser = await userController.createUser(user)
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('create', () => {
afterEach(async () => {
await deleteAllPermissions()
})
it('should respond with new permission when principalType is user', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...permission, principalId: dbUser.id })
.expect(200)
expect(res.body.permissionId).toBeTruthy()
expect(res.body.path).toEqual(permission.path)
expect(res.body.type).toEqual(permission.type)
expect(res.body.setting).toEqual(permission.setting)
expect(res.body.user).toBeTruthy()
})
it('should respond with new permission when principalType is group', async () => {
const dbGroup = await groupController.createGroup(group)
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: 'group',
principalId: dbGroup.groupId
})
.expect(200)
expect(res.body.permissionId).toBeTruthy()
expect(res.body.path).toEqual(permission.path)
expect(res.body.type).toEqual(permission.type)
expect(res.body.setting).toEqual(permission.setting)
expect(res.body.group).toBeTruthy()
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.send(permission)
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
const res = await request(app)
.post('/SASjsApi/permission')
.auth(accessToken, { type: 'bearer' })
.send(permission)
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if path is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
path: undefined
})
.expect(400)
expect(res.text).toEqual(`"path" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if path is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
path: '/some/random/api/endpoint'
})
.expect(400)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if type is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
type: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"type" must be [Route]')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if type is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
type: undefined
})
.expect(400)
expect(res.text).toEqual(`"type" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
setting: undefined
})
.expect(400)
expect(res.text).toEqual(`"setting" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
setting: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principalType is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: undefined
})
.expect(400)
expect(res.text).toEqual(`"principalType" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principal type is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"principalType" must be one of [user, group]')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principalId is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: undefined
})
.expect(400)
expect(res.text).toEqual(`"principalId" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principalId is not a number', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: 'someCharacters'
})
.expect(400)
expect(res.text).toEqual('"principalId" must be a number')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if adding permission for admin user', async () => {
const adminUser = await userController.createUser({
...user,
username: 'adminUser',
isAdmin: true
})
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: adminUser.id
})
.expect(400)
expect(res.text).toEqual('Can not add permission for admin user.')
expect(res.body).toEqual({})
})
it('should respond with Not Found (404) if user is not found', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: 123
})
.expect(404)
expect(res.text).toEqual('User not found.')
expect(res.body).toEqual({})
})
it('should respond with Not Found (404) if group is not found', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: 'group',
principalId: 123
})
.expect(404)
expect(res.text).toEqual('Group not found.')
expect(res.body).toEqual({})
})
it('should respond with Conflict (409) if permission already exists', async () => {
await permissionController.createPermission({
...permission,
principalId: dbUser.id
})
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...permission, principalId: dbUser.id })
.expect(409)
expect(res.text).toEqual(
'Permission already exists with provided Path, Type and User.'
)
expect(res.body).toEqual({})
})
})
describe('update', () => {
let dbPermission: PermissionDetailsResponse | undefined
beforeAll(async () => {
dbPermission = await permissionController.createPermission({
...permission,
principalId: dbUser.id
})
})
afterEach(async () => {
await deleteAllPermissions()
})
it('should respond with updated permission', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ setting: PermissionSettingForRoute.deny })
.expect(200)
expect(res.body.setting).toEqual('Deny')
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'update' + user.username
})
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is missing', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(400)
expect(res.text).toEqual(`"setting" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is invalid', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({
setting: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
expect(res.body).toEqual({})
})
it('should respond with not found (404) if permission with provided id does not exist', async () => {
const res = await request(app)
.patch('/SASjsApi/permission/123')
.auth(adminAccessToken, { type: 'bearer' })
.send({
setting: PermissionSettingForRoute.deny
})
.expect(404)
expect(res.text).toEqual('Permission not found.')
expect(res.body).toEqual({})
})
})
describe('delete', () => {
it('should delete permission', async () => {
const dbPermission = await permissionController.createPermission({
...permission,
principalId: dbUser.id
})
const res = await request(app)
.delete(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.text).toEqual('Permission Deleted!')
})
it('should respond with not found (404) if permission with provided id does not exists', async () => {
const res = await request(app)
.delete('/SASjsApi/permission/123')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
expect(res.text).toEqual('Permission not found.')
})
})
describe('get', () => {
beforeAll(async () => {
await permissionController.createPermission({
...permission,
path: '/test-1',
principalId: dbUser.id
})
await permissionController.createPermission({
...permission,
path: '/test-2',
principalId: dbUser.id
})
})
it('should give a list of all permissions when user is admin', async () => {
const res = await request(app)
.get('/SASjsApi/permission/')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toHaveLength(2)
})
it(`should give a list of user's own permissions when user is not admin`, async () => {
const nonAdminUser = await userController.createUser({
...user,
username: 'get' + user.username
})
const accessToken = await generateAndSaveToken(nonAdminUser.id)
await permissionController.createPermission({
path: '/test-1',
type: PermissionType.route,
principalType: PrincipalType.user,
principalId: nonAdminUser.id,
setting: PermissionSettingForRoute.grant
})
const permissionCount = 1
const res = await request(app)
.get('/SASjsApi/permission/')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toHaveLength(permissionCount)
})
})
describe('verify', () => {
beforeAll(async () => {
await permissionController.createPermission({
...permission,
path: '/SASjsApi/drive/deploy',
principalId: dbUser.id
})
})
beforeEach(() => {
jest
.spyOn(DriveController.prototype, 'deploy')
.mockImplementation((deployPayload) =>
Promise.resolve({
status: 'success',
message: 'Files deployed successfully to @sasjs/server.'
})
)
})
afterEach(() => {
jest.resetAllMocks()
})
it('should create files in SASJS drive', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
await request(app)
.get('/SASjsApi/drive/deploy')
.auth(accessToken, { type: 'bearer' })
.send(deployPayload)
.expect(200)
})
it('should respond unauthorized', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
await request(app)
.get('/SASjsApi/drive/deploy/upload')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
})
})
})
const generateSaveTokenAndCreateUser = async (
someUser?: any
): Promise<string> => {
const dbUser = await userController.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id)
}
const generateAndSaveToken = async (userId: number) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
})
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
return adminAccessToken
}
const deleteAllPermissions = async () => {
const { collections } = mongoose.connection
const collection = collections['permissions']
await collection.deleteMany({})
}

View File

@@ -4,7 +4,13 @@ import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import {
UserController,
PermissionController,
PermissionType,
PermissionSettingForRoute,
PrincipalType
} from '../../../controllers/'
import {
generateAccessToken,
saveTokensInDB,
@@ -41,12 +47,22 @@ describe('stp', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
let accessToken: string
const userController = new UserController()
const permissionController = new PermissionController()
beforeAll(async () => {
app = await appPromise
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
accessToken = await generateSaveTokenAndCreateUser(user)
const dbUser = await userController.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id)
await permissionController.createPermission({
path: '/SASjsApi/stp/execute',
type: PermissionType.route,
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSettingForRoute.grant
})
})
afterAll(async () => {

View File

@@ -770,12 +770,14 @@ describe('user', () => {
{
id: expect.anything(),
username: adminUser.username,
displayName: adminUser.displayName
displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin
},
{
id: expect.anything(),
username: user.username,
displayName: user.displayName
displayName: user.displayName,
isAdmin: user.isAdmin
}
])
})
@@ -796,12 +798,14 @@ describe('user', () => {
{
id: expect.anything(),
username: adminUser.username,
displayName: adminUser.displayName
displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin
},
{
id: expect.anything(),
username: 'randomUser',
displayName: user.displayName
displayName: user.displayName,
isAdmin: user.isAdmin
}
])
})

View File

@@ -39,12 +39,11 @@ describe('web', () => {
describe('home', () => {
it('should respond with CSRF Token', async () => {
await request(app)
.get('/')
.expect(
'set-cookie',
/_csrf=.*; Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=.*; Path=\//
)
const res = await request(app).get('/').expect(200)
expect(res.text).toMatch(
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/
)
})
})
@@ -79,7 +78,8 @@ describe('web', () => {
expect(res.body.user).toEqual({
id: expect.any(Number),
username: user.username,
displayName: user.displayName
displayName: user.displayName,
isAdmin: user.isAdmin
})
})
})
@@ -153,10 +153,10 @@ describe('web', () => {
const getCSRF = async (app: Express) => {
// make request to get CSRF
const { header } = await request(app).get('/')
const { header, text } = await request(app).get('/')
const cookies = header['set-cookie'].join()
const csrfToken = extractCSRF(cookies)
const csrfToken = extractCSRF(text)
return { csrfToken, cookies }
}
@@ -176,7 +176,7 @@ const performLogin = async (
return { cookies: newCookies }
}
const extractCSRF = (cookies: string) =>
/_csrf=(.*); Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=(.*); Path=\//.exec(
cookies
)![2]
const extractCSRF = (text: string) =>
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
text
)![1]

View File

@@ -1,5 +1,6 @@
import path from 'path'
import express, { Request } from 'express'
import { authenticateAccessToken } from '../../middlewares'
import { folderExists } from '@sasjs/utils'
import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils'
@@ -9,7 +10,7 @@ const appStreams: { [key: string]: string } = {}
const router = express.Router()
router.get('/', async (req, res) => {
router.get('/', authenticateAccessToken, async (req, res) => {
const content = appStreamHtml(process.appStreamConfig)
res.cookie('XSRF-TOKEN', req.csrfToken())
@@ -66,7 +67,7 @@ export const publishAppStream = async (
return {}
}
router.get(`/*`, function (req: Request, res, next) {
router.get(`/*`, authenticateAccessToken, function (req: Request, res, next) {
const reqPath = req.path.replace(/^\//, '')
// Redirecting to url with trailing slash for appStream base URL only

View File

@@ -26,6 +26,7 @@ export const style = `<style>
}
.app-container .app img{
width: 100%;
height: calc(100% - 30px);
margin-bottom: 10px;
border-radius: 10px;
}

View File

@@ -11,11 +11,15 @@ webRouter.get('/', async (req, res) => {
try {
response = await controller.home()
} catch (_) {
response = 'Web Build is not present'
response = '<html><head></head><body>Web Build is not present</body></html>'
} finally {
res.cookie('XSRF-TOKEN', req.csrfToken())
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${req.csrfToken()}; Max-Age=86400; SameSite=Strict; Path=/;'</script>`
const injectedContent = response?.replace(
'</head>',
`${codeToInject}</head>`
)
return res.send(response)
return res.send(injectedContent)
}
})

View File

@@ -2,5 +2,6 @@ export interface TreeNode {
name: string
relativePath: string
absolutePath: string
isFolder: boolean
children: Array<TreeNode>
}

View File

@@ -3,10 +3,12 @@ declare namespace NodeJS {
sasLoc?: string
nodeLoc?: string
driveLoc: string
logsLoc: string
sasSessionController?: import('../../controllers/internal').SASSessionController
jsSessionController?: import('../../controllers/internal').JSSessionController
appStreamConfig: import('../').AppStreamConfig
logger: import('@sasjs/utils/logger').Logger
runTimes: import('../../utils').RunTimeType[]
secrets: import('../../model/Configuration').ConfigurationType
}
}

View File

@@ -5,6 +5,8 @@ import { AppStreamConfig } from '../types'
import { getAppStreamConfigPath } from './file'
export const loadAppStreamConfig = async () => {
process.appStreamConfig = {}
if (process.env.NODE_ENV === 'test') return
const appStreamConfigPath = getAppStreamConfigPath()
@@ -21,7 +23,6 @@ export const loadAppStreamConfig = async () => {
} catch (_) {
appStreamConfig = {}
}
process.appStreamConfig = {}
for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) {
const { appLoc, streamWebFolder, streamLogo } = entry

View File

@@ -9,7 +9,5 @@ export const connectDB = async () => {
}
console.log('Connected to DB!')
await seedDB()
return mongoose.connection
return seedDB()
}

View File

@@ -22,6 +22,8 @@ export const getDesktopUserAutoExecPath = () =>
export const getSasjsRootFolder = () => process.driveLoc
export const getLogFolder = () => process.logsLoc
export const getAppStreamConfigPath = () =>
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
@@ -32,8 +34,6 @@ export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
export const getLogFolder = () => path.join(getSasjsRootFolder(), 'logs')
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
export const getSessionsFolder = () =>

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateAccessToken = (data: InfoJWT) =>
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, {
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
expiresIn: '1day'
})

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateAuthCode = (data: InfoJWT) =>
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, {
jwt.sign(data, process.secrets.AUTH_CODE_SECRET, {
expiresIn: '30s'
})

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateRefreshToken = (data: InfoJWT) =>
jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, {
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
expiresIn: '30 days'
})

View File

@@ -0,0 +1,35 @@
import { Request } from 'express'
const StaticAuthorizedRoutes = [
'/AppStream',
'/SASjsApi/code/execute',
'/SASjsApi/stp/execute',
'/SASjsApi/drive/deploy',
'/SASjsApi/drive/deploy/upload',
'/SASjsApi/drive/file',
'/SASjsApi/drive/folder',
'/SASjsApi/drive/fileTree',
'/SASjsApi/drive/rename'
]
export const getAuthorizedRoutes = () => {
const streamingApps = Object.keys(process.appStreamConfig)
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
}
export const getPath = (req: Request) => {
const { baseUrl, path: reqPath } = req
if (baseUrl === '/AppStream') {
const appStream = reqPath.split('/')[1]
// removing trailing slash of URLs
return (baseUrl + '/' + appStream).replace(/\/$/, '')
}
return (baseUrl + reqPath).replace(/\/$/, '')
}
export const isAuthorizingRoute = (req: Request): boolean =>
getAuthorizedRoutes().includes(getPath(req))

View File

@@ -5,7 +5,7 @@ import { RunTimeType } from '.'
export const getRunTimeAndFilePath = async (programPath: string) => {
const ext = path.extname(programPath)
// If programPath (_program) is provided with a ".sas" or ".js" extension
// If programPath (_program) is provided with a ".sas" or ".js" extension
// we should use that extension to determine the appropriate runTime
if (ext && Object.values(RunTimeType).includes(ext.slice(1) as RunTimeType)) {
const runTime = ext.slice(1)

View File

@@ -8,6 +8,7 @@ export * from './file'
export * from './generateAccessToken'
export * from './generateAuthCode'
export * from './generateRefreshToken'
export * from './getAuthorizedRoutes'
export * from './getCertificates'
export * from './getDesktopFields'
export * from './getPreProgramVariables'
@@ -15,6 +16,7 @@ export * from './getRunTimeAndFilePath'
export * from './getServerUrl'
export * from './instantiateLogger'
export * from './isDebugOn'
export * from './isPublicRoute'
export * from './zipped'
export * from './parseLogToArray'
export * from './removeTokensInDB'

View File

@@ -0,0 +1,31 @@
import { Request } from 'express'
import { getPath } from './getAuthorizedRoutes'
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
import Permission from '../model/Permission'
import { PermissionSettingForRoute } from '../controllers'
import { RequestUser } from '../types'
export const isPublicRoute = async (req: Request): Promise<boolean> => {
const group = await Group.findOne({ name: PUBLIC_GROUP_NAME })
if (group) {
const path = getPath(req)
const groupPermission = await Permission.findOne({
path,
group: group?._id
})
if (groupPermission?.setting === PermissionSettingForRoute.grant)
return true
}
return false
}
export const publicUser: RequestUser = {
userId: 0,
clientId: 'public_app',
username: 'publicUser',
displayName: 'Public User',
isAdmin: false,
isActive: true
}

View File

@@ -1,5 +1,88 @@
import Client from '../model/Client'
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
import User from '../model/User'
import Configuration, { ConfigurationType } from '../model/Configuration'
import { randomBytes } from 'crypto'
export const SECRETS: ConfigurationType = {
ACCESS_TOKEN_SECRET: randomBytes(64).toString('hex'),
REFRESH_TOKEN_SECRET: randomBytes(64).toString('hex'),
AUTH_CODE_SECRET: randomBytes(64).toString('hex'),
SESSION_SECRET: randomBytes(64).toString('hex')
}
export const seedDB = async (): Promise<ConfigurationType> => {
// Checking if client is already in the database
const clientExist = await Client.findOne({ clientId: CLIENT.clientId })
if (!clientExist) {
const client = new Client(CLIENT)
await client.save()
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
}
// Checking if 'AllUsers' Group is already in the database
let groupExist = await Group.findOne({ name: GROUP.name })
if (!groupExist) {
const group = new Group(GROUP)
groupExist = await group.save()
console.log(`DB Seed - Group created: ${GROUP.name}`)
}
// Checking if 'Public' Group is already in the database
const publicGroupExist = await Group.findOne({ name: PUBLIC_GROUP.name })
if (!publicGroupExist) {
const group = new Group(PUBLIC_GROUP)
await group.save()
console.log(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
}
// Checking if user is already in the database
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
if (!usernameExist) {
const user = new User(ADMIN_USER)
usernameExist = await user.save()
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
}
if (!groupExist.hasUser(usernameExist)) {
groupExist.addUser(usernameExist)
console.log(
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${GROUP.name}'`
)
}
// checking if configuration is present in the database
let configExist = await Configuration.findOne()
if (!configExist) {
const configuration = new Configuration(SECRETS)
configExist = await configuration.save()
console.log('DB Seed - configuration added')
}
return {
ACCESS_TOKEN_SECRET: configExist.ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET: configExist.REFRESH_TOKEN_SECRET,
AUTH_CODE_SECRET: configExist.AUTH_CODE_SECRET,
SESSION_SECRET: configExist.SESSION_SECRET
}
}
const GROUP = {
name: 'AllUsers',
description: 'Group contains all users'
}
const PUBLIC_GROUP = {
name: PUBLIC_GROUP_NAME,
description:
'A special group that can be used to bypass authentication for particular routes.'
}
const CLIENT = {
clientId: 'clientID1',
@@ -13,23 +96,3 @@ const ADMIN_USER = {
isAdmin: true,
isActive: true
}
export const seedDB = async () => {
// Checking if client is already in the database
const clientExist = await Client.findOne({ clientId: CLIENT.clientId })
if (!clientExist) {
const client = new Client(CLIENT)
await client.save()
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
}
// Checking if user is already in the database
const usernameExist = await User.findOne({ username: ADMIN_USER.username })
if (!usernameExist) {
const user = new User(ADMIN_USER)
await user.save()
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
}
}

View File

@@ -1,16 +1,28 @@
import path from 'path'
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
import { getDesktopFields, ModeType, RunTimeType } from '.'
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
export const setProcessVariables = async () => {
const { MODE, RUN_TIMES } = process.env
if (MODE === ModeType.Server) {
// NOTE: when exporting app.js as agent for supertest
// it should prevent connecting to the real database
if (process.env.NODE_ENV !== 'test') {
const secrets = await connectDB()
process.secrets = secrets
} else {
process.secrets = SECRETS
}
}
if (process.env.NODE_ENV === 'test') {
process.driveLoc = path.join(process.cwd(), 'sasjs_root')
return
}
const { MODE, RUN_TIMES } = process.env
process.runTimes = (RUN_TIMES?.split(',') as RunTimeType[]) ?? []
if (MODE === ModeType.Server) {
@@ -28,7 +40,16 @@ export const setProcessVariables = async () => {
await createFolder(absPath)
process.driveLoc = getRealPath(absPath)
const { LOG_LOCATION } = process.env
const absLogsPath = getAbsolutePath(
LOG_LOCATION ?? `sasjs_root${path.sep}logs`,
process.cwd()
)
await createFolder(absLogsPath)
process.logsLoc = getRealPath(absLogsPath)
console.log('sasLoc: ', process.sasLoc)
console.log('sasDrive: ', process.driveLoc)
console.log('sasLogs: ', process.logsLoc)
console.log('runTimes: ', process.runTimes)
}

View File

@@ -1,4 +1,4 @@
import { extractHeaders } from '..'
import { extractHeaders } from '../extractHeaders'
describe('extractHeaders', () => {
it('should return valid http headers', () => {

View File

@@ -1,4 +1,4 @@
import { parseLogToArray } from '..'
import { parseLogToArray } from '../parseLogToArray'
describe('parseLogToArray', () => {
it('should parse log to array type', () => {

View File

@@ -1,6 +1,6 @@
import path from 'path'
import { MulterFile } from '../types/Upload'
import { listFilesInFolder, readFileBinary } from '@sasjs/utils'
import { listFilesInFolder, readFileBinary, isWindows } from '@sasjs/utils'
interface FilenameMapSingle {
fieldName: string
@@ -118,7 +118,9 @@ export const generateFileUploadJSCode = async (
if (fileName.includes('req_file')) {
fileCount++
const filePath = path.join(sessionFolder, fileName)
uploadCode += `\nconst _WEBIN_FILEREF${fileCount} = fs.readFileSync('${filePath}')`
uploadCode += `\nconst _WEBIN_FILEREF${fileCount} = fs.readFileSync('${
isWindows() ? filePath.replace(/\\/g, '\\\\') : filePath
}')`
uploadCode += `\nconst _WEBIN_FILENAME${fileCount} = '${filesNamesMap[fileName].originalName}'`
uploadCode += `\nconst _WEBIN_NAME${fileCount} = '${filesNamesMap[fileName].fieldName}'`
}

View File

@@ -1,5 +1,10 @@
import Joi from 'joi'
import { RunTimeType } from '.'
import {
PermissionType,
PermissionSettingForRoute,
PrincipalType
} from '../controllers/permission'
import { getAuthorizedRoutes } from './getAuthorizedRoutes'
const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
const passwordSchema = Joi.string().min(6).max(1024)
@@ -86,6 +91,30 @@ export const registerClientValidation = (data: any): Joi.ValidationResult =>
clientSecret: Joi.string().required()
}).validate(data)
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
Joi.object({
path: Joi.string()
.required()
.valid(...getAuthorizedRoutes()),
type: Joi.string()
.required()
.valid(...Object.values(PermissionType)),
setting: Joi.string()
.required()
.valid(...Object.values(PermissionSettingForRoute)),
principalType: Joi.string()
.required()
.valid(...Object.values(PrincipalType)),
principalId: Joi.number().required()
}).validate(data)
export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
Joi.object({
setting: Joi.string()
.required()
.valid(...Object.values(PermissionSettingForRoute))
}).validate(data)
export const deployValidation = (data: any): Joi.ValidationResult =>
Joi.object({
appLoc: Joi.string().pattern(/^\//).required().min(2),
@@ -116,9 +145,23 @@ export const fileParamValidation = (data: any): Joi.ValidationResult =>
_filePath: filePathSchema
}).validate(data)
export const folderParamValidation = (data: any): Joi.ValidationResult =>
export const folderParamValidation = (
data: any,
folderPathRequired?: boolean
): Joi.ValidationResult =>
Joi.object({
_folderPath: Joi.string()
_folderPath: folderPathRequired ? Joi.string().required() : Joi.string()
}).validate(data)
export const folderBodyValidation = (data: any): Joi.ValidationResult =>
Joi.object({
folderPath: Joi.string().required()
}).validate(data)
export const renameBodyValidation = (data: any): Joi.ValidationResult =>
Joi.object({
oldPath: Joi.string().required(),
newPath: Joi.string().required()
}).validate(data)
export const runCodeValidation = (data: any): Joi.ValidationResult =>

View File

@@ -78,33 +78,7 @@ const verifyMODE = (): string[] => {
}
if (process.env.MODE === ModeType.Server) {
const {
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET,
AUTH_CODE_SECRET,
SESSION_SECRET,
DB_CONNECT
} = process.env
if (!ACCESS_TOKEN_SECRET)
errors.push(
`- ACCESS_TOKEN_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!REFRESH_TOKEN_SECRET)
errors.push(
`- REFRESH_TOKEN_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!AUTH_CODE_SECRET)
errors.push(
`- AUTH_CODE_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!SESSION_SECRET)
errors.push(
`- SESSION_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
const { DB_CONNECT } = process.env
if (process.env.NODE_ENV !== 'test')
if (!DB_CONNECT)
@@ -151,8 +125,27 @@ const verifyCORS = (): string[] => {
if (CORS) {
const corsTypes = Object.values(CorsType)
if (!corsTypes.includes(CORS as CorsType))
errors.push(`- CORS '${CORS}'\n - valid options ${corsTypes}`)
if (CORS === CorsType.ENABLED) {
const { WHITELIST } = process.env
const urls = WHITELIST?.trim()
.split(' ')
.filter((url) => !!url)
if (urls?.length) {
urls.forEach((url) => {
if (!url.startsWith('http://') && !url.startsWith('https://'))
errors.push(
`- CORS '${CORS}'\n - provided WHITELIST ${url} is not valid`
)
})
} else {
errors.push(`- CORS '${CORS}'\n - provide at least one WHITELIST URL`)
}
}
} else {
const { MODE } = process.env
process.env.CORS =

View File

@@ -28,7 +28,8 @@ export const extractJSONFromZip = async (zipFile: Express.Multer.File) => {
for await (const entry of zip) {
const fileName = entry.path as string
if (fileName.toUpperCase().endsWith('.JSON') && fileName === fileInZip) {
// grab the first json found in .zip
if (fileName.toUpperCase().endsWith('.JSON')) {
fileContent = await entry.buffer()
break
} else {

View File

@@ -12,40 +12,44 @@
},
"tags": [
{
"name": "Info",
"description": "Get Server Info"
},
{
"name": "Session",
"description": "Get Session information"
},
{
"name": "User",
"description": "Operations about users"
"name": "Auth",
"description": "Operations about auth"
},
{
"name": "Client",
"description": "Operations about clients"
},
{
"name": "Auth",
"description": "Operations about auth"
"name": "CODE",
"description": "Execution of code (various runtimes are supported)"
},
{
"name": "Drive",
"description": "Operations about drive"
"description": "Operations on SASjs Drive"
},
{
"name": "Group",
"description": "Operations about group"
"description": "Operations on groups and group memberships"
},
{
"name": "Info",
"description": "Get Server Information"
},
{
"name": "Permission",
"description": "Operations about permissions"
},
{
"name": "Session",
"description": "Get Session information"
},
{
"name": "STP",
"description": "Operations about STP"
"description": "Execution of Stored Programs"
},
{
"name": "CODE",
"description": "Operations on SAS code"
"name": "User",
"description": "Operations with users"
},
{
"name": "Web",

572
web/package-lock.json generated
View File

@@ -10,7 +10,7 @@
"dependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@mui/icons-material": "^5.0.3",
"@mui/icons-material": "^5.8.4",
"@mui/lab": "^5.0.0-alpha.50",
"@mui/material": "^5.0.3",
"@mui/styles": "^5.0.1",
@@ -27,7 +27,7 @@
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^17.0.2",
"react-monaco-editor": "^0.48.0",
"react-router-dom": "^5.3.0",
"react-router-dom": "^6.3.0",
"react-toastify": "^9.0.1"
},
"devDependencies": {
@@ -1836,9 +1836,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.16.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz",
"integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==",
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.6.tgz",
"integrity": "sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==",
"dependencies": {
"regenerator-runtime": "^0.13.4"
},
@@ -2284,6 +2284,58 @@
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
"integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
"dependencies": {
"@jridgewell/set-array": "^1.0.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.9"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
"integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.0",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.14",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
"integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@mui/core": {
"version": "5.0.0-alpha.54",
"resolved": "https://registry.npmjs.org/@mui/core/-/core-5.0.0-alpha.54.tgz",
@@ -2312,19 +2364,23 @@
}
},
"node_modules/@mui/icons-material": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.1.0.tgz",
"integrity": "sha512-GD2cNZ2XTqoxX6DMUg+tos1fDUVg6kXWxwo9UuBiRIhK8N+B7CG7vjRDf28LLmewcqIjxqy+T2SEVqDLy1FOYQ==",
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.8.4.tgz",
"integrity": "sha512-9Z/vyj2szvEhGWDvb+gG875bOGm8b8rlHBKOD1+nA3PcgC3fV6W1AU6pfOorPeBfH2X4mb9Boe97vHvaSndQvA==",
"dependencies": {
"@babel/runtime": "^7.16.0"
"@babel/runtime": "^7.17.2"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui"
},
"peerDependencies": {
"@mui/material": "^5.0.0",
"@types/react": "^16.8.6 || ^17.0.0",
"react": "^17.0.2"
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -3933,11 +3989,9 @@
}
},
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true,
"peer": true,
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==",
"bin": {
"acorn": "bin/acorn"
},
@@ -6518,18 +6572,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/espree/node_modules/acorn": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
"integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
@@ -7128,16 +7170,11 @@
}
},
"node_modules/history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
"integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
"dependencies": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
"resolve-pathname": "^3.0.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0",
"value-equal": "^1.0.1"
"@babel/runtime": "^7.7.6"
}
},
"node_modules/hoist-non-react-statics": {
@@ -7198,20 +7235,6 @@
"node": ">=12"
}
},
"node_modules/html-minifier-terser/node_modules/acorn": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
"integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==",
"dev": true,
"optional": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/html-minifier-terser/node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
@@ -7221,46 +7244,6 @@
"node": ">= 12"
}
},
"node_modules/html-minifier-terser/node_modules/source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
"dev": true,
"engines": {
"node": ">= 8"
}
},
"node_modules/html-minifier-terser/node_modules/terser": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
"integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
"dev": true,
"dependencies": {
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"acorn": "^8.5.0"
},
"peerDependenciesMeta": {
"acorn": {
"optional": true
}
}
},
"node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
},
"node_modules/html-webpack-plugin": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz",
@@ -7829,11 +7812,6 @@
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -8392,19 +8370,6 @@
"node": ">=4"
}
},
"node_modules/mini-create-react-context": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz",
"integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==",
"dependencies": {
"@babel/runtime": "^7.12.1",
"tiny-warning": "^1.0.3"
},
"peerDependencies": {
"prop-types": "^15.0.0",
"react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -8967,14 +8932,6 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"node_modules/path-to-regexp": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
"integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
"dependencies": {
"isarray": "0.0.1"
}
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -9362,47 +9319,29 @@
"react": "^17.x"
}
},
"node_modules/react-router": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz",
"integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==",
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1",
"mini-create-react-context": "^0.4.0",
"path-to-regexp": "^1.7.0",
"prop-types": "^15.6.2",
"react-is": "^16.6.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
},
"peerDependencies": {
"react": ">=15"
}
},
"node_modules/react-router-dom": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz",
"integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz",
"integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==",
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"loose-envify": "^1.3.1",
"prop-types": "^15.6.2",
"react-router": "5.2.1",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
"history": "^5.2.0",
"react-router": "6.3.0"
},
"peerDependencies": {
"react": ">=15"
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-router/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
"node_modules/react-router-dom/node_modules/react-router": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz",
"integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==",
"dependencies": {
"history": "^5.2.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-toastify": {
"version": "9.0.1",
@@ -9679,11 +9618,6 @@
"node": ">=4"
}
},
"node_modules/resolve-pathname": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
"integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -10337,6 +10271,28 @@
"node": ">=6"
}
},
"node_modules/terser": {
"version": "5.14.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
"integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
"dependencies": {
"@jridgewell/source-map": "^0.3.2",
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/terser/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -10349,11 +10305,6 @@
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"dev": true
},
"node_modules/tiny-invariant": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz",
"integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg=="
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@@ -10733,11 +10684,6 @@
"node": ">= 0.10"
}
},
"node_modules/value-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
"integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -11184,17 +11130,6 @@
"node": ">=10.0.0"
}
},
"node_modules/webpack/node_modules/acorn": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
"integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/webpack/node_modules/acorn-import-assertions": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz",
@@ -11203,11 +11138,6 @@
"acorn": "^8"
}
},
"node_modules/webpack/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/webpack/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -11276,30 +11206,6 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/webpack/node_modules/terser": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
"integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
"dependencies": {
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"acorn": "^8.5.0"
},
"peerDependenciesMeta": {
"acorn": {
"optional": true
}
}
},
"node_modules/webpack/node_modules/terser-webpack-plugin": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz",
@@ -11333,14 +11239,6 @@
}
}
},
"node_modules/webpack/node_modules/terser/node_modules/source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
"engines": {
"node": ">= 8"
}
},
"node_modules/webpack/node_modules/webpack-sources": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
@@ -12642,9 +12540,9 @@
}
},
"@babel/runtime": {
"version": "7.16.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.3.tgz",
"integrity": "sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==",
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.6.tgz",
"integrity": "sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -12974,6 +12872,49 @@
}
}
},
"@jridgewell/gen-mapping": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
"integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
"requires": {
"@jridgewell/set-array": "^1.0.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
"integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w=="
},
"@jridgewell/set-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw=="
},
"@jridgewell/source-map": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
"integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
"requires": {
"@jridgewell/gen-mapping": "^0.3.0",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"@jridgewell/sourcemap-codec": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"@jridgewell/trace-mapping": {
"version": "0.3.14",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
"integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
"requires": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"@mui/core": {
"version": "5.0.0-alpha.54",
"resolved": "https://registry.npmjs.org/@mui/core/-/core-5.0.0-alpha.54.tgz",
@@ -12989,11 +12930,11 @@
}
},
"@mui/icons-material": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.1.0.tgz",
"integrity": "sha512-GD2cNZ2XTqoxX6DMUg+tos1fDUVg6kXWxwo9UuBiRIhK8N+B7CG7vjRDf28LLmewcqIjxqy+T2SEVqDLy1FOYQ==",
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.8.4.tgz",
"integrity": "sha512-9Z/vyj2szvEhGWDvb+gG875bOGm8b8rlHBKOD1+nA3PcgC3fV6W1AU6pfOorPeBfH2X4mb9Boe97vHvaSndQvA==",
"requires": {
"@babel/runtime": "^7.16.0"
"@babel/runtime": "^7.17.2"
}
},
"@mui/lab": {
@@ -14170,11 +14111,9 @@
}
},
"acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true,
"peer": true
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz",
"integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w=="
},
"acorn-jsx": {
"version": "5.3.2",
@@ -16121,12 +16060,6 @@
"eslint-visitor-keys": "^3.3.0"
},
"dependencies": {
"acorn": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
"integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==",
"dev": true
},
"eslint-visitor-keys": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
@@ -16587,16 +16520,11 @@
"dev": true
},
"history": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
"integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
"integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
"requires": {
"@babel/runtime": "^7.1.2",
"loose-envify": "^1.2.0",
"resolve-pathname": "^3.0.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0",
"value-equal": "^1.0.1"
"@babel/runtime": "^7.7.6"
}
},
"hoist-non-react-statics": {
@@ -16650,44 +16578,11 @@
"terser": "^5.10.0"
},
"dependencies": {
"acorn": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
"integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==",
"dev": true,
"optional": true,
"peer": true
},
"commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"dev": true
},
"source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
"dev": true
},
"terser": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
"integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
"dev": true,
"requires": {
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.20"
},
"dependencies": {
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
}
}
}
}
},
@@ -17084,11 +16979,6 @@
"is-docker": "^2.0.0"
}
},
"isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
},
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -17530,15 +17420,6 @@
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
},
"mini-create-react-context": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz",
"integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==",
"requires": {
"@babel/runtime": "^7.12.1",
"tiny-warning": "^1.0.3"
}
},
"minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -17961,14 +17842,6 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"path-to-regexp": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
"integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
"requires": {
"isarray": "0.0.1"
}
},
"path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -18260,44 +18133,25 @@
"prop-types": "^15.8.1"
}
},
"react-router": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz",
"integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==",
"react-router-dom": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.3.0.tgz",
"integrity": "sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==",
"requires": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1",
"mini-create-react-context": "^0.4.0",
"path-to-regexp": "^1.7.0",
"prop-types": "^15.6.2",
"react-is": "^16.6.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
"history": "^5.2.0",
"react-router": "6.3.0"
},
"dependencies": {
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
"react-router": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.3.0.tgz",
"integrity": "sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==",
"requires": {
"history": "^5.2.0"
}
}
}
},
"react-router-dom": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz",
"integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==",
"requires": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"loose-envify": "^1.3.1",
"prop-types": "^15.6.2",
"react-router": "5.2.1",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
}
},
"react-toastify": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.0.1.tgz",
@@ -18520,11 +18374,6 @@
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
},
"resolve-pathname": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
"integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="
},
"retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -19014,6 +18863,24 @@
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="
},
"terser": {
"version": "5.14.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
"integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
"requires": {
"@jridgewell/source-map": "^0.3.2",
"acorn": "^8.5.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"dependencies": {
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
}
}
},
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -19026,11 +18893,6 @@
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"dev": true
},
"tiny-invariant": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz",
"integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg=="
},
"tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@@ -19320,11 +19182,6 @@
"homedir-polyfill": "^1.0.1"
}
},
"value-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
"integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="
},
"vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -19380,22 +19237,12 @@
"webpack-sources": "^3.2.2"
},
"dependencies": {
"acorn": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
"integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ=="
},
"acorn-import-assertions": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz",
"integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==",
"requires": {}
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -19442,23 +19289,6 @@
"has-flag": "^4.0.0"
}
},
"terser": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.10.0.tgz",
"integrity": "sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA==",
"requires": {
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.20"
},
"dependencies": {
"source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ=="
}
}
},
"terser-webpack-plugin": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.1.tgz",

View File

@@ -3,13 +3,13 @@
"version": "0.1.0",
"private": true,
"scripts": {
"start": "npx webpack-dev-server --config webpack.dev.ts --hot",
"build": "npx webpack --config webpack.prod.ts"
"start": "webpack-dev-server --config webpack.dev.ts --hot",
"build": "webpack --config webpack.prod.ts"
},
"dependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@mui/icons-material": "^5.0.3",
"@mui/icons-material": "^5.8.4",
"@mui/lab": "^5.0.0-alpha.50",
"@mui/material": "^5.0.3",
"@mui/styles": "^5.0.1",
@@ -26,7 +26,7 @@
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^17.0.2",
"react-monaco-editor": "^0.48.0",
"react-router-dom": "^5.3.0",
"react-router-dom": "^6.3.0",
"react-toastify": "^9.0.1"
},
"devDependencies": {

View File

@@ -1,12 +1,11 @@
import React, { useContext } from 'react'
import { Route, HashRouter, Switch } from 'react-router-dom'
import { Route, HashRouter, Routes } from 'react-router-dom'
import { ThemeProvider } from '@mui/material/styles'
import { theme } from './theme'
import Login from './components/login'
import Header from './components/header'
import Home from './components/home'
import Drive from './containers/Drive'
import Studio from './containers/Studio'
import Settings from './containers/Settings'
@@ -22,11 +21,9 @@ function App() {
<ThemeProvider theme={theme}>
<HashRouter>
<Header />
<Switch>
<Route path="/">
<Login />
</Route>
</Switch>
<Routes>
<Route path="*" element={<Login />} />
</Routes>
</HashRouter>
</ThemeProvider>
)
@@ -36,23 +33,12 @@ function App() {
<ThemeProvider theme={theme}>
<HashRouter>
<Header />
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route exact path="/SASjsDrive">
<Drive />
</Route>
<Route exact path="/SASjsStudio">
<Studio />
</Route>
<Route exact path="/SASjsSettings">
<Settings />
</Route>
<Route exact path="/SASjsLogon">
<AuthCode />
</Route>
</Switch>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/SASjsStudio" element={<Studio />} />
<Route path="/SASjsSettings" element={<Settings />} />
<Route path="/SASjsLogon" element={<AuthCode />} />
</Routes>
<ToastContainer />
</HashRouter>
</ThemeProvider>

View File

@@ -0,0 +1,49 @@
import React from 'react'
import {
Button,
Dialog,
DialogContent,
DialogActions,
Typography
} from '@mui/material'
import { styled } from '@mui/material/styles'
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
'& .MuiDialogContent-root': {
padding: theme.spacing(2)
},
'& .MuiDialogActions-root': {
padding: theme.spacing(1)
}
}))
type DeleteConfirmationModalProps = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
message: string
_delete: () => void
}
const DeleteConfirmationModal = ({
open,
setOpen,
message,
_delete
}: DeleteConfirmationModalProps) => {
return (
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
<DialogContent dividers>
<Typography gutterBottom>{message}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button color="error" onClick={() => _delete()}>
Delete
</Button>
</DialogActions>
</BootstrapDialog>
)
}
export default DeleteConfirmationModal

View File

@@ -0,0 +1,35 @@
import React, { Dispatch, SetStateAction } from 'react'
import DialogTitle from '@mui/material/DialogTitle'
import IconButton from '@mui/material/IconButton'
import CloseIcon from '@mui/icons-material/Close'
export interface DialogTitleProps {
id: string
children?: React.ReactNode
handleOpen: Dispatch<SetStateAction<boolean>>
}
export const BootstrapDialogTitle = (props: DialogTitleProps) => {
const { children, handleOpen, ...other } = props
return (
<DialogTitle sx={{ m: 0, p: 2 }} {...other}>
{children}
{handleOpen ? (
<IconButton
aria-label="close"
onClick={() => handleOpen(false)}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500]
}}
>
<CloseIcon />
</IconButton>
) : null}
</DialogTitle>
)
}

View File

@@ -0,0 +1,83 @@
import React, { useState } from 'react'
import { Button, DialogActions, DialogContent, TextField } from '@mui/material'
import { BootstrapDialogTitle } from './dialogTitle'
import { BootstrapDialog } from './modal'
type FilePathInputModalProps = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
saveFile: (filePath: string) => void
}
const FilePathInputModal = ({
open,
setOpen,
saveFile
}: FilePathInputModalProps) => {
const [filePath, setFilePath] = useState('')
const [hasError, setHasError] = useState(false)
const [errorText, setErrorText] = useState('')
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
const specialChars = /[`!@#$%^&*()_+\-=[\]{};':"\\|,<>?~]/
const fileExtension = /\.(exe|sh|htaccess)$/i
if (specialChars.test(value)) {
setHasError(true)
setErrorText('can not have special characters')
} else if (fileExtension.test(value)) {
setHasError(true)
setErrorText('can not save file with extensions [exe, sh, htaccess]')
} else {
setHasError(false)
setErrorText('')
}
setFilePath(value)
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (hasError || !filePath) return
saveFile(filePath)
}
return (
<BootstrapDialog fullWidth onClose={() => setOpen(false)} open={open}>
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
Save File
</BootstrapDialogTitle>
<DialogContent dividers>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
autoFocus
variant="outlined"
label="File Path"
value={filePath}
onChange={handleChange}
error={hasError}
helperText={errorText}
/>
</form>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => saveFile(filePath)}
disabled={hasError || !filePath}
>
Save
</Button>
</DialogActions>
</BootstrapDialog>
)
}
export default FilePathInputModal

View File

@@ -1,17 +1,19 @@
import React, { useState, useEffect, useContext } from 'react'
import { Link, useHistory, useLocation } from 'react-router-dom'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import {
Box,
AppBar,
Toolbar,
Tabs,
Tab,
Button,
Menu,
MenuItem
MenuItem,
IconButton,
Typography
} from '@mui/material'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import SettingsIcon from '@mui/icons-material/Settings'
import { OpenInNew, Settings, Menu as MenuIcon } from '@mui/icons-material'
import Username from './username'
import { AppContext } from '../context/appContext'
@@ -24,37 +26,44 @@ const baseUrl =
const validTabs = ['/', '/SASjsDrive', '/SASjsStudio']
const Header = (props: any) => {
const history = useHistory()
const navigate = useNavigate()
const { pathname } = useLocation()
const appContext = useContext(AppContext)
const [tabValue, setTabValue] = useState(
validTabs.includes(pathname) ? pathname : '/'
)
const [anchorEl, setAnchorEl] = useState<
(EventTarget & HTMLButtonElement) | null
>(null)
const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(null)
const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(
null
)
const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElNav(event.currentTarget)
}
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget)
}
const handleCloseNavMenu = () => {
setAnchorElNav(null)
}
const handleCloseUserMenu = () => {
setAnchorElUser(null)
}
useEffect(() => {
setTabValue(validTabs.includes(pathname) ? pathname : '/')
}, [pathname])
const handleMenu = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleTabChange = (event: React.SyntheticEvent, value: string) => {
setTabValue(value)
}
const handleLogout = () => {
if (appContext.logout) {
handleClose()
handleCloseUserMenu()
appContext.logout()
}
}
@@ -64,60 +73,129 @@ const Header = (props: any) => {
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar variant="dense">
<img
src="logo.png"
alt="logo"
style={{
width: '35px',
cursor: 'pointer',
marginRight: '25px'
}}
onClick={() => {
setTabValue('/')
history.push('/')
}}
/>
<Tabs
indicatorColor="secondary"
value={tabValue}
onChange={handleTabChange}
>
<Tab label="Home" value="/" to="/" component={Link} />
<Tab
label="Drive"
value="/SASjsDrive"
to="/SASjsDrive"
component={Link}
<Box sx={{ display: { xs: 'none', md: 'flex' } }}>
<img
src="logo.png"
alt="logo"
style={{
width: '35px',
height: '35px',
marginTop: '9px',
cursor: 'pointer',
marginRight: '25px'
}}
onClick={() => {
setTabValue('/')
navigate('/')
}}
/>
<Tab
label="Studio"
value="/SASjsStudio"
to="/SASjsStudio"
component={Link}
<Tabs
indicatorColor="secondary"
value={tabValue}
onChange={handleTabChange}
>
<Tab label="Home" value="/" to="/" component={Link} />
<Tab
label="Studio"
value="/SASjsStudio"
to="/SASjsStudio"
component={Link}
/>
</Tabs>
<Button
href={`${baseUrl}/AppStream`}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNew />}
>
Apps
</Button>
</Box>
<Box sx={{ flexGrow: 1, display: { xs: 'flex', md: 'none' } }}>
<IconButton size="large" onClick={handleOpenNavMenu} color="inherit">
<MenuIcon />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorElNav}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
open={!!anchorElNav}
onClose={handleCloseNavMenu}
sx={{
display: { xs: 'block', md: 'none' }
}}
>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to="/"
onClick={handleCloseNavMenu}
variant="contained"
color="primary"
>
Home
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to="/SASjsStudio"
onClick={handleCloseNavMenu}
variant="contained"
color="primary"
>
Studio
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
href={`${baseUrl}/AppStream`}
target="_blank"
rel="noreferrer"
onClick={handleCloseNavMenu}
variant="contained"
color="primary"
endIcon={<OpenInNew />}
>
Apps
</Button>
</MenuItem>
</Menu>
</Box>
<Box sx={{ display: { xs: 'flex', md: 'none' } }}>
<img
src="logo.png"
alt="logo"
style={{
width: '35px',
height: '35px',
marginTop: '2px',
cursor: 'pointer',
marginRight: '25px'
}}
onClick={() => {
setTabValue('/')
navigate('/')
}}
/>
</Tabs>
<Button
href={`${baseUrl}/SASjsApi`}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNewIcon />}
>
API Docs
</Button>
<Button
href={`${baseUrl}/AppStream`}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNewIcon />}
>
App Stream
</Button>
</Box>
<div
style={{
display: 'flex',
@@ -127,11 +205,11 @@ const Header = (props: any) => {
>
<Username
username={appContext.displayName || appContext.username}
onClickHandler={handleMenu}
onClickHandler={handleOpenUserMenu}
/>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorEl={anchorElUser}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
@@ -141,26 +219,70 @@ const Header = (props: any) => {
vertical: 'top',
horizontal: 'center'
}}
open={!!anchorEl}
onClose={handleClose}
open={!!anchorElUser}
onClose={handleCloseUserMenu}
>
{appContext.loggedIn && (
<MenuItem
sx={{ justifyContent: 'center', display: { md: 'none' } }}
>
<Typography
variant="h5"
sx={{ border: '1px solid black', padding: '5px' }}
>
{appContext.displayName || appContext.username}
</Typography>
</MenuItem>
)}
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to="/SASjsSettings"
onClick={handleClose}
onClick={handleCloseUserMenu}
variant="contained"
color="primary"
startIcon={<SettingsIcon />}
startIcon={<Settings />}
>
Settings
</Button>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ justifyContent: 'center' }}>
<Button variant="contained" color="primary">
Logout
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
href={'https://server.sasjs.io'}
target="_blank"
rel="noreferrer"
variant="contained"
size="large"
color="primary"
endIcon={<OpenInNew />}
>
Docs
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
href={`${baseUrl}/SASjsApi`}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNew />}
>
API
</Button>
</MenuItem>
{appContext.loggedIn && (
<MenuItem
onClick={handleLogout}
sx={{ justifyContent: 'center' }}
>
<Button variant="contained" color="primary">
Logout
</Button>
</MenuItem>
)}
</Menu>
</div>
</Toolbar>

View File

@@ -22,7 +22,7 @@ const Login = () => {
username,
password
}).catch((err: any) => {
setErrorMessage(err.response.data)
setErrorMessage(err.response?.data || err.toString())
return {}
})
@@ -30,6 +30,7 @@ const Login = () => {
appContext.setUserId?.(user.id)
appContext.setUsername?.(user.username)
appContext.setDisplayName?.(user.displayName)
appContext.setIsAdmin?.(user.isAdmin)
appContext.setLoggedIn?.(loggedIn)
}
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
import { Typography, Dialog, DialogContent } from '@mui/material'
import { styled } from '@mui/material/styles'
import { BootstrapDialogTitle } from './dialogTitle'
export const BootstrapDialog = styled(Dialog)(({ theme }) => ({
'& .MuiDialogContent-root': {
padding: theme.spacing(2)
},
'& .MuiDialogActions-root': {
padding: theme.spacing(1)
}
}))
type ModalProps = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
title: string
payload: string
}
const Modal = (props: ModalProps) => {
const { open, setOpen, title, payload } = props
return (
<div>
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
{title}
</BootstrapDialogTitle>
<DialogContent dividers>
<Typography gutterBottom>
<span style={{ fontFamily: 'monospace' }}>{payload}</span>
</Typography>
</DialogContent>
</BootstrapDialog>
</div>
)
}
export default Modal

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react'
import { Button, DialogActions, DialogContent, TextField } from '@mui/material'
import { BootstrapDialogTitle } from './dialogTitle'
import { BootstrapDialog } from './modal'
type NameInputModalProps = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
title: string
isFolder: boolean
actionLabel: string
action: (name: string) => void
defaultName?: string
}
const NameInputModal = ({
open,
setOpen,
title,
isFolder,
actionLabel,
action,
defaultName
}: NameInputModalProps) => {
const [name, setName] = useState('')
const [hasError, setHasError] = useState(false)
const [errorText, setErrorText] = useState('')
useEffect(() => {
if (defaultName) setName(defaultName)
}, [defaultName])
const handleFocus = (
event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement, Element>
) => {
if (defaultName) {
event.target.select()
}
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
const folderNameRegex = /[`!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/
const fileNameRegex = /[`!@#$%^&*()_+\-=[\]{};':"\\|,<>/?~]/
const fileNameExtensionRegex = /.(exe|sh|htaccess)$/i
const specialChars = isFolder ? folderNameRegex : fileNameRegex
if (specialChars.test(value)) {
setHasError(true)
setErrorText('can not have special characters')
} else if (!isFolder && fileNameExtensionRegex.test(value)) {
setHasError(true)
setErrorText('can not add file with extensions [exe, sh, htaccess]')
} else {
setHasError(false)
setErrorText('')
}
setName(value)
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (hasError || !name) return
action(name)
}
return (
<BootstrapDialog fullWidth onClose={() => setOpen(false)} open={open}>
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
{title}
</BootstrapDialogTitle>
<DialogContent dividers>
<form onSubmit={handleSubmit}>
<TextField
id="input-box"
fullWidth
autoFocus
onFocus={handleFocus}
variant="outlined"
label={isFolder ? 'Folder Name' : 'File Name'}
value={name}
onChange={handleChange}
error={hasError}
helperText={errorText}
/>
</form>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => action(name)}
disabled={hasError || !name}
>
{actionLabel}
</Button>
</DialogActions>
</BootstrapDialog>
)
}
export default NameInputModal

View File

@@ -0,0 +1,62 @@
import React, { Dispatch, SetStateAction } from 'react'
import Snackbar from '@mui/material/Snackbar'
import MuiAlert, { AlertProps } from '@mui/material/Alert'
import Slide, { SlideProps } from '@mui/material/Slide'
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
props,
ref
) {
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
})
const Transition = (props: SlideProps) => {
return <Slide {...props} direction="up" />
}
export enum AlertSeverityType {
Success = 'success',
Warning = 'warning',
Info = 'info',
Error = 'error'
}
type BootstrapSnackbarProps = {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
message: string
severity: AlertSeverityType
}
const BootstrapSnackbar = ({
open,
setOpen,
message,
severity
}: BootstrapSnackbarProps) => {
const handleClose = (
event: React.SyntheticEvent | Event,
reason?: string
) => {
if (reason === 'clickaway') {
return
}
setOpen(false)
}
return (
<Snackbar
open={open}
autoHideDuration={3000}
onClose={handleClose}
TransitionComponent={Transition}
>
<Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}>
{message}
</Alert>
</Snackbar>
)
}
export default BootstrapSnackbar

247
web/src/components/tree.tsx Normal file
View File

@@ -0,0 +1,247 @@
import React, { useEffect, useState } from 'react'
import { Menu, MenuItem } from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import DeleteConfirmationModal from './deleteConfirmationModal'
import NameInputModal from './nameInputModal'
import { TreeNode } from '../utils/types'
type Props = {
node: TreeNode
selectedFilePath: string
handleSelect: (filePath: string) => void
deleteNode: (path: string, isFolder: boolean) => void
addFile: (path: string) => void
addFolder: (path: string) => void
rename: (oldPath: string, newPath: string) => void
defaultExpanded?: string[]
}
const TreeView = ({
node,
selectedFilePath,
handleSelect,
deleteNode,
addFile,
addFolder,
rename,
defaultExpanded
}: Props) => {
return (
<ul
style={{
listStyle: 'none',
padding: '0.25rem 0.85rem',
width: 'max-content'
}}
>
<TreeViewNode
node={node}
selectedFilePath={selectedFilePath}
handleSelect={handleSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
rename={rename}
defaultExpanded={defaultExpanded}
/>
</ul>
)
}
export default TreeView
const TreeViewNode = ({
node,
selectedFilePath,
handleSelect,
deleteNode,
addFile,
addFolder,
rename,
defaultExpanded
}: Props) => {
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
useState(false)
const [deleteConfirmationModalMessage, setDeleteConfirmationModalMessage] =
useState('')
const [defaultInputModalName, setDefaultInputModalName] = useState('')
const [nameInputModalOpen, setNameInputModalOpen] = useState(false)
const [nameInputModalTitle, setNameInputModalTitle] = useState('')
const [nameInputModalActionLabel, setNameInputModalActionLabel] = useState('')
const [nameInputModalForFolder, setNameInputModalForFolder] = useState(false)
const [childVisible, setChildVisibility] = useState(false)
const [contextMenu, setContextMenu] = useState<{
mouseX: number
mouseY: number
} | null>(null)
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
setContextMenu(
contextMenu === null
? {
mouseX: event.clientX + 2,
mouseY: event.clientY - 6
}
: null
)
}
const hasChild = node.children.length ? true : false
const handleItemClick = () => {
if (node.children.length) {
setChildVisibility((v) => !v)
return
}
handleSelect(node.relativePath)
}
useEffect(() => {
if (defaultExpanded && defaultExpanded[0] === node.relativePath) {
setChildVisibility(true)
defaultExpanded.shift()
}
}, [defaultExpanded, node.relativePath])
const handleDeleteItemClick = () => {
setContextMenu(null)
setDeleteConfirmationModalOpen(true)
setDeleteConfirmationModalMessage(
`Are you sure you want to delete ${node.isFolder ? 'folder' : 'file'} "${
node.relativePath
}"?`
)
}
const deleteConfirm = () => {
setDeleteConfirmationModalOpen(false)
deleteNode(node.relativePath, node.isFolder)
}
const handleNewFolderItemClick = () => {
setContextMenu(null)
setNameInputModalOpen(true)
setNameInputModalTitle('Add Folder')
setNameInputModalActionLabel('Add')
setNameInputModalForFolder(true)
setDefaultInputModalName('')
}
const handleNewFileItemClick = () => {
setContextMenu(null)
setNameInputModalOpen(true)
setNameInputModalTitle('Add File')
setNameInputModalActionLabel('Add')
setNameInputModalForFolder(false)
setDefaultInputModalName('')
}
const addFileFolder = (name: string) => {
setNameInputModalOpen(false)
const path = node.relativePath + '/' + name
if (nameInputModalForFolder) addFolder(path)
else addFile(path)
}
const handleRenameItemClick = () => {
setContextMenu(null)
setNameInputModalOpen(true)
setNameInputModalTitle('Rename')
setNameInputModalActionLabel('Rename')
setNameInputModalForFolder(node.isFolder)
setDefaultInputModalName(node.relativePath.split('/').pop() ?? '')
}
const renameFileFolder = (name: string) => {
setNameInputModalOpen(false)
const oldPath = node.relativePath
const splittedPath = node.relativePath.split('/')
splittedPath.splice(-1, 1, name)
const newPath = splittedPath.join('/')
rename(oldPath, newPath)
}
return (
<div onContextMenu={handleContextMenu} style={{ cursor: 'context-menu' }}>
<li style={{ display: 'list-item' }}>
<div
className={`tree-item-label ${
selectedFilePath === node.relativePath ? 'selected' : ''
}`}
onClick={() => handleItemClick()}
>
{hasChild &&
(childVisible ? <ExpandMoreIcon /> : <ChevronRightIcon />)}
<div>{node.name}</div>
</div>
{hasChild &&
childVisible &&
node.children.map((child, index) => (
<TreeView
key={node.relativePath + '-' + index}
node={child}
selectedFilePath={selectedFilePath}
handleSelect={handleSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
rename={rename}
defaultExpanded={defaultExpanded}
/>
))}
</li>
<DeleteConfirmationModal
open={deleteConfirmationModalOpen}
setOpen={setDeleteConfirmationModalOpen}
message={deleteConfirmationModalMessage}
_delete={deleteConfirm}
/>
<NameInputModal
open={nameInputModalOpen}
setOpen={setNameInputModalOpen}
title={nameInputModalTitle}
isFolder={nameInputModalForFolder}
actionLabel={nameInputModalActionLabel}
action={
nameInputModalActionLabel === 'Add' ? addFileFolder : renameFileFolder
}
defaultName={defaultInputModalName}
/>
<Menu
open={contextMenu !== null}
onClose={() => setContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
>
{node.isFolder && (
<div>
<MenuItem onClick={handleNewFolderItemClick}>Add Folder</MenuItem>
<MenuItem
disabled={!node.relativePath}
onClick={handleNewFileItemClick}
>
Add File
</MenuItem>
</div>
)}
<MenuItem disabled={!node.relativePath} onClick={handleRenameItemClick}>
Rename
</MenuItem>
<MenuItem disabled={!node.relativePath} onClick={handleDeleteItemClick}>
Delete
</MenuItem>
</Menu>
</div>
)
}

View File

@@ -20,7 +20,14 @@ const Username = (props: any) => {
) : (
<AccountCircle></AccountCircle>
)}
<Typography variant="h6" sx={{ color: 'white', padding: '0 8px' }}>
<Typography
variant="h6"
sx={{
color: 'white',
padding: '0 8px',
display: { xs: 'none', md: 'flex' }
}}
>
{props.username}
</Typography>
</IconButton>

View File

@@ -1,106 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import CssBaseline from '@mui/material/CssBaseline'
import Box from '@mui/material/Box'
import SideBar from './sideBar'
import Main from './main'
export interface TreeNode {
name: string
relativePath: string
absolutePath: string
children: Array<TreeNode>
}
const Drive = () => {
const location = useLocation()
const baseUrl = window.location.origin
const [selectedFilePath, setSelectedFilePath] = useState('')
const [directoryData, setDirectoryData] = useState<TreeNode | null>(null)
const setFilePathOnMount = useCallback(() => {
const queryParams = new URLSearchParams(location.search)
setSelectedFilePath(queryParams.get('filePath') ?? '')
}, [location.search])
useEffect(() => {
axios
.get(`/SASjsApi/drive/fileTree`)
.then((res: any) => {
if (res.data && res.data?.status === 'success') {
setDirectoryData(res.data.tree)
}
})
.catch((err) => {
console.log(err)
})
setFilePathOnMount()
}, [setFilePathOnMount])
const handleSelect = (node: TreeNode) => {
if (node.children.length) return
if (!node.name.includes('.')) return
window.history.pushState(
'',
'',
`${baseUrl}/#/SASjsDrive?filePath=${node.relativePath}`
)
setSelectedFilePath(node.relativePath)
}
const removeFileFromTree = (path: string) => {
if (directoryData) {
const newTree = JSON.parse(JSON.stringify(directoryData)) as TreeNode
findAndRemoveNode(newTree, newTree, path)
setDirectoryData(newTree)
}
}
const findAndRemoveNode = (
node: TreeNode,
parentNode: TreeNode,
path: string
) => {
if (node.relativePath === path) {
removeNodeFromParent(parentNode, path)
return true
}
if (Array.isArray(node.children)) {
for (let i = 0; i < node.children.length; i++) {
if (findAndRemoveNode(node.children[i], node, path)) return
}
}
}
const removeNodeFromParent = (parent: TreeNode, path: string) => {
const index = parent.children.findIndex(
(node) => node.relativePath === path
)
if (index !== -1) {
parent.children.splice(index, 1)
}
}
return (
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<SideBar
selectedFilePath={selectedFilePath}
directoryData={directoryData}
handleSelect={handleSelect}
/>
<Main
selectedFilePath={selectedFilePath}
removeFileFromTree={removeFileFromTree}
/>
</Box>
)
}
export default Drive

View File

@@ -1,173 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
import Editor from 'react-monaco-editor'
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Button from '@mui/material/Button'
import Toolbar from '@mui/material/Toolbar'
import CircularProgress from '@mui/material/CircularProgress'
type Props = {
selectedFilePath: string
removeFileFromTree: (path: string) => void
}
const Main = (props: Props) => {
const baseUrl = window.location.origin
const [isLoading, setIsLoading] = useState(false)
const [fileContentBeforeEdit, setFileContentBeforeEdit] = useState('')
const [fileContent, setFileContent] = useState('')
const [editMode, setEditMode] = useState(false)
useEffect(() => {
if (props.selectedFilePath) {
setIsLoading(true)
axios
.get(`/SASjsApi/drive/file?_filePath=${props.selectedFilePath}`)
.then((res: any) => {
setFileContent(res.data)
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setIsLoading(false)
})
}
}, [props.selectedFilePath])
const handleDeleteBtnClick = () => {
setIsLoading(true)
const filePath = props.selectedFilePath
axios
.delete(`/SASjsApi/drive/file?_filePath=${filePath}`)
.then((res) => {
setFileContent('')
props.removeFileFromTree(filePath)
window.history.pushState('', '', `${baseUrl}/#/SASjsDrive`)
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setIsLoading(false)
})
}
const handleEditSaveBtnClick = () => {
if (!editMode) {
setFileContentBeforeEdit(fileContent)
setEditMode(true)
} else {
setIsLoading(true)
const formData = new FormData()
const stringBlob = new Blob([fileContent], { type: 'text/plain' })
formData.append('file', stringBlob, 'filename.sas')
formData.append('filePath', props.selectedFilePath)
axios
.patch(`/SASjsApi/drive/file`, formData)
.then((res) => {
setEditMode(false)
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setIsLoading(false)
})
}
}
const handleCancelExecuteBtnClick = () => {
if (editMode) {
setFileContent(fileContentBeforeEdit)
setEditMode(false)
} else {
window.open(
`${baseUrl}/SASjsApi/stp/execute?_program=${props.selectedFilePath}`
)
}
}
return (
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar />
<Paper
sx={{
height: '75vh',
padding: '10px',
overflow: 'auto',
position: 'relative'
}}
elevation={3}
>
{isLoading && (
<CircularProgress
style={{ position: 'absolute', left: '50%', top: '50%' }}
/>
)}
{!isLoading && props?.selectedFilePath && !editMode && (
<code style={{ whiteSpace: 'break-spaces' }}>{fileContent}</code>
)}
{!isLoading && props?.selectedFilePath && editMode && (
<Editor
height="95%"
language="sas"
value={fileContent}
onChange={(val) => {
if (val) setFileContent(val)
}}
/>
)}
</Paper>
<Stack
spacing={3}
direction="row"
sx={{ justifyContent: 'center', marginTop: '20px' }}
>
<Button
variant="contained"
onClick={handleDeleteBtnClick}
disabled={isLoading || !props?.selectedFilePath}
>
Delete
</Button>
<Button
variant="contained"
onClick={handleEditSaveBtnClick}
disabled={isLoading || !props?.selectedFilePath}
>
{!editMode ? 'Edit' : 'Save'}
</Button>
<Button
variant="contained"
onClick={handleCancelExecuteBtnClick}
disabled={isLoading || !props?.selectedFilePath}
>
{editMode ? 'Cancel' : 'Execute'}
</Button>
{props?.selectedFilePath && (
<Button
variant="contained"
component={Link}
to={`/SASjsStudio?_program=${props.selectedFilePath}`}
>
Open in Studio
</Button>
)}
</Stack>
</Box>
)
}
export default Main

View File

@@ -1,100 +0,0 @@
import React, { useMemo } from 'react'
import { makeStyles } from '@mui/styles'
import Box from '@mui/material/Box'
import Drawer from '@mui/material/Drawer'
import Toolbar from '@mui/material/Toolbar'
import ListItem from '@mui/material/ListItem'
import ListItemText from '@mui/material/ListItemText'
import TreeView from '@mui/lab/TreeView'
import TreeItem from '@mui/lab/TreeItem'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import { TreeNode } from '.'
const useStyles = makeStyles(() => ({
root: {
'& .MuiTreeItem-content': {
width: 'auto'
}
},
listItem: {
padding: 0
}
}))
const drawerWidth = 240
type Props = {
selectedFilePath: string
directoryData: TreeNode | null
handleSelect: (node: TreeNode) => void
}
const SideBar = ({ selectedFilePath, directoryData, handleSelect }: Props) => {
const classes = useStyles()
const defaultExpanded = useMemo(() => {
const splittedPath = selectedFilePath.split('/')
const arr = ['']
let nodeId = ''
splittedPath.forEach((path) => {
if (path !== '') {
nodeId += '/' + path
arr.push(nodeId)
}
})
return arr
}, [selectedFilePath])
const renderTree = (nodes: TreeNode) => (
<TreeItem
classes={{ root: classes.root }}
key={nodes.relativePath}
nodeId={nodes.relativePath}
label={
<ListItem
className={classes.listItem}
onClick={() => handleSelect(nodes)}
>
<ListItemText primary={nodes.name} />
</ListItem>
}
>
{Array.isArray(nodes.children)
? nodes.children.map((node) => renderTree(node))
: null}
</TreeItem>
)
return (
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' }
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
{directoryData && (
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
defaultExpanded={defaultExpanded}
selected={defaultExpanded.slice(-1)}
>
{renderTree(directoryData)}
</TreeView>
)}
</Box>
</Drawer>
)
}
export default SideBar

View File

@@ -0,0 +1,255 @@
import React, { useState, useEffect, Dispatch, SetStateAction } from 'react'
import axios from 'axios'
import {
Button,
Grid,
Dialog,
DialogContent,
DialogActions,
TextField,
CircularProgress,
Autocomplete
} from '@mui/material'
import { styled } from '@mui/material/styles'
import { BootstrapDialogTitle } from '../../components/dialogTitle'
import {
UserResponse,
GroupResponse,
RegisterPermissionPayload
} from '../../utils/types'
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
'& .MuiDialogContent-root': {
padding: theme.spacing(2)
},
'& .MuiDialogActions-root': {
padding: theme.spacing(1)
}
}))
type AddPermissionModalProps = {
open: boolean
handleOpen: Dispatch<SetStateAction<boolean>>
addPermission: (
permissions: RegisterPermissionPayload[],
permissionType: string,
principalType: string,
principal: string,
permissionSetting: string
) => void
}
const AddPermissionModal = ({
open,
handleOpen,
addPermission
}: AddPermissionModalProps) => {
const [paths, setPaths] = useState<string[]>([])
const [loadingPaths, setLoadingPaths] = useState(false)
const [selectedPaths, setSelectedPaths] = useState<string[]>([])
const [permissionType, setPermissionType] = useState('Route')
const [principalType, setPrincipalType] = useState('Group')
const [userPrincipal, setUserPrincipal] = useState<UserResponse>()
const [groupPrincipal, setGroupPrincipal] = useState<GroupResponse>()
const [permissionSetting, setPermissionSetting] = useState('Grant')
const [loadingPrincipals, setLoadingPrincipals] = useState(false)
const [userPrincipals, setUserPrincipals] = useState<UserResponse[]>([])
const [groupPrincipals, setGroupPrincipals] = useState<GroupResponse[]>([])
useEffect(() => {
setLoadingPaths(true)
axios
.get('/SASjsApi/info/authorizedRoutes')
.then((res: any) => {
if (res.data) {
setPaths(res.data.paths)
}
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setLoadingPaths(false)
})
}, [])
useEffect(() => {
setLoadingPrincipals(true)
axios
.get(`/SASjsApi/${principalType.toLowerCase()}`)
.then((res: any) => {
if (res.data) {
if (principalType.toLowerCase() === 'user') {
const users: UserResponse[] = res.data
const nonAdminUsers = users.filter((user) => !user.isAdmin)
setUserPrincipals(nonAdminUsers)
} else {
setGroupPrincipals(res.data)
}
}
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setLoadingPrincipals(false)
})
}, [principalType])
const handleAddPermission = () => {
const permissions: RegisterPermissionPayload[] = []
selectedPaths.forEach((path) => {
const addPermissionPayload: any = {
path,
type: permissionType,
setting: permissionSetting,
principalType: principalType.toLowerCase(),
principalId:
principalType.toLowerCase() === 'user'
? userPrincipal?.id
: groupPrincipal?.groupId
}
permissions.push(addPermissionPayload)
})
const principal =
principalType.toLowerCase() === 'user'
? userPrincipal?.username
: groupPrincipal?.name
addPermission(
permissions,
permissionType,
principalType,
principal!,
permissionSetting
)
}
const addButtonDisabled =
!selectedPaths.length ||
(principalType.toLowerCase() === 'user' ? !userPrincipal : !groupPrincipal)
return (
<BootstrapDialog onClose={() => handleOpen(false)} open={open}>
<BootstrapDialogTitle
id="add-permission-dialog-title"
handleOpen={handleOpen}
>
Add Permission
</BootstrapDialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<Grid item xs={12}>
<Autocomplete
multiple
disableClearable
options={paths}
filterSelectedOptions
value={selectedPaths}
onChange={(event: any, newValue: string[]) => {
setSelectedPaths(newValue)
}}
renderInput={(params) => <TextField {...params} label="Paths" />}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
options={['Route']}
disableClearable
value={permissionType}
onChange={(event: any, newValue: string) =>
setPermissionType(newValue)
}
renderInput={(params) =>
loadingPaths ? (
<CircularProgress />
) : (
<TextField {...params} label="Permission Type" />
)
}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
options={['Group', 'User']}
disableClearable
value={principalType}
onChange={(event: any, newValue: string) =>
setPrincipalType(newValue)
}
renderInput={(params) => (
<TextField {...params} label="Principal Type" />
)}
/>
</Grid>
<Grid item xs={12}>
{principalType.toLowerCase() === 'user' ? (
<Autocomplete
options={userPrincipals}
getOptionLabel={(option) => option.displayName}
disableClearable
value={userPrincipal}
onChange={(event: any, newValue: UserResponse) =>
setUserPrincipal(newValue)
}
renderInput={(params) =>
loadingPrincipals ? (
<CircularProgress />
) : (
<TextField {...params} label="Principal" />
)
}
/>
) : (
<Autocomplete
options={groupPrincipals}
getOptionLabel={(option) => option.name}
disableClearable
value={groupPrincipal}
onChange={(event: any, newValue: GroupResponse) =>
setGroupPrincipal(newValue)
}
renderInput={(params) =>
loadingPrincipals ? (
<CircularProgress />
) : (
<TextField {...params} label="Principal" />
)
}
/>
)}
</Grid>
<Grid item xs={12}>
<Autocomplete
options={['Grant', 'Deny']}
disableClearable
value={permissionSetting}
onChange={(event: any, newValue: string) =>
setPermissionSetting(newValue)
}
renderInput={(params) => (
<TextField {...params} label="Settings" />
)}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button
variant="outlined"
onClick={handleAddPermission}
disabled={addButtonDisabled}
>
Add
</Button>
</DialogActions>
</BootstrapDialog>
)
}
export default AddPermissionModal

View File

@@ -0,0 +1,120 @@
import React from 'react'
import { Typography, DialogContent } from '@mui/material'
import { BootstrapDialog } from '../../components/modal'
import { BootstrapDialogTitle } from '../../components/dialogTitle'
import { PermissionResponse } from '../../utils/types'
export interface PermissionResponsePayload {
permissionType: string
principalType: string
principal: string
permissionSetting: string
existingPermissions: PermissionResponse[]
newAddedPermissions: PermissionResponse[]
updatedPermissions: PermissionResponse[]
errorPaths: string[]
}
type Props = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
payload: PermissionResponsePayload
}
const PermissionResponseModal = ({ open, setOpen, payload }: Props) => {
const newAddedPermissionsLength = payload.newAddedPermissions.length
const updatedPermissionsLength = payload.updatedPermissions.length
const existingPermissionsLength = payload.existingPermissions.length
const appliedPermissionsLength =
newAddedPermissionsLength + updatedPermissionsLength
return (
<div>
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
<BootstrapDialogTitle
id="permission-response-modal"
handleOpen={setOpen}
>
Permission Response
</BootstrapDialogTitle>
<DialogContent dividers>
<Typography sx={{ fontWeight: 'bold', marginBottom: '15px' }}>
{`${appliedPermissionsLength} "${payload.permissionSetting}", "${
payload.permissionType
}", "${payload.principalType}", "${payload.principal}" ${
appliedPermissionsLength > 1 ? 'Rules' : 'Rule'
}`}{' '}
Applied:
</Typography>
{newAddedPermissionsLength > 0 && (
<>
<Typography>
{`${newAddedPermissionsLength} ${
newAddedPermissionsLength > 1 ? 'Rules' : 'Rule'
}`}{' '}
Added:
</Typography>
<ul>
{payload.newAddedPermissions.map((permission, index) => (
<li key={index}>{permission.path}</li>
))}
</ul>
</>
)}
{updatedPermissionsLength > 0 && (
<>
<Typography>
{` ${updatedPermissionsLength} ${
updatedPermissionsLength > 1 ? 'Rules' : 'Rule'
}`}{' '}
Updated:
</Typography>
<ul>
{payload.updatedPermissions.map((permission, index) => (
<li key={index}>{permission.path}</li>
))}
</ul>
</>
)}
{existingPermissionsLength > 0 && (
<>
<Typography>
{`${existingPermissionsLength} ${
existingPermissionsLength > 1 ? 'Rules' : 'Rule'
}`}{' '}
Unchanged:
</Typography>
<ul>
{payload.existingPermissions.map((permission, index) => (
<li key={index}>{permission.path}</li>
))}
</ul>
</>
)}
{payload.errorPaths.length > 0 && (
<>
<Typography style={{ color: 'red', marginTop: '10px' }}>
Errors occurred for following paths:
</Typography>
<ul>
{payload.errorPaths.map((path, index) => (
<li key={index}>
<Typography>{path}</Typography>
</li>
))}
</ul>
</>
)}
</DialogContent>
</BootstrapDialog>
</div>
)
}
export default PermissionResponseModal

View File

@@ -1,12 +1,15 @@
import * as React from 'react'
import React, { useState, useContext } from 'react'
import { Box, Paper, Tab, styled } from '@mui/material'
import TabContext from '@mui/lab/TabContext'
import TabList from '@mui/lab/TabList'
import TabPanel from '@mui/lab/TabPanel'
import Permission from './permission'
import Profile from './profile'
import { AppContext, ModeType } from '../../context/appContext'
const StyledTab = styled(Tab)({
background: 'black',
margin: '0 5px 5px 0'
@@ -17,7 +20,8 @@ const StyledTabpanel = styled(TabPanel)({
})
const Settings = () => {
const [value, setValue] = React.useState('profile')
const appContext = useContext(AppContext)
const [value, setValue] = useState('profile')
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
setValue(newValue)
@@ -27,11 +31,20 @@ const Settings = () => {
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
marginTop: '65px'
}}
>
<TabContext value={value}>
<Box component={Paper} sx={{ margin: '0 5px', height: '92vh' }}>
<Box
component={Paper}
sx={{
margin: '0 5px',
height: { md: '92vh' },
display: 'flex',
justifyContent: 'center'
}}
>
<TabList
TabIndicatorProps={{
style: {
@@ -42,11 +55,17 @@ const Settings = () => {
onChange={handleChange}
>
<StyledTab label="Profile" value="profile" />
{appContext.mode === ModeType.Server && (
<StyledTab label="Permissions" value="permission" />
)}
</TabList>
</Box>
<StyledTabpanel value="profile">
<Profile />
</StyledTabpanel>
<StyledTabpanel value="permission">
<Permission />
</StyledTabpanel>
</TabContext>
</Box>
)

View File

@@ -0,0 +1,575 @@
import React, { useState, useEffect, useContext, useCallback } from 'react'
import axios from 'axios'
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Grid,
CircularProgress,
IconButton,
Tooltip,
Typography,
Popover
} from '@mui/material'
import FilterListIcon from '@mui/icons-material/FilterList'
import AddIcon from '@mui/icons-material/Add'
import EditIcon from '@mui/icons-material/Edit'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import { styled } from '@mui/material/styles'
import Modal from '../../components/modal'
import PermissionFilterModal from './permissionFilterModal'
import AddPermissionModal from './addPermissionModal'
import PermissionResponseModal, {
PermissionResponsePayload
} from './addPermissionResponseModal'
import UpdatePermissionModal from './updatePermissionModal'
import DeleteConfirmationModal from '../../components/deleteConfirmationModal'
import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar'
import {
GroupDetailsResponse,
PermissionResponse,
RegisterPermissionPayload
} from '../../utils/types'
import {
findExistingPermission,
findUpdatingPermission
} from '../../utils/helper'
import { AppContext } from '../../context/appContext'
const BootstrapTableCell = styled(TableCell)({
textAlign: 'left'
})
const BootstrapGridItem = styled(Grid)({
'&.MuiGrid-item': {
maxWidth: '100%'
}
})
export enum PrincipalType {
User = 'User',
Group = 'Group'
}
const Permission = () => {
const appContext = useContext(AppContext)
const [isLoading, setIsLoading] = useState(false)
const [openModal, setOpenModal] = useState(false)
const [modalTitle, setModalTitle] = useState('')
const [modalPayload, setModalPayload] = useState('')
const [openSnackbar, setOpenSnackbar] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
const [snackbarSeverity, setSnackbarSeverity] = useState<AlertSeverityType>(
AlertSeverityType.Success
)
const [addPermissionModalOpen, setAddPermissionModalOpen] = useState(false)
const [openPermissionResponseModal, setOpenPermissionResponseModal] =
useState(false)
const [permissionResponsePayload, setPermissionResponsePayload] =
useState<PermissionResponsePayload>({
permissionType: '',
principalType: '',
principal: '',
permissionSetting: '',
existingPermissions: [],
newAddedPermissions: [],
updatedPermissions: [],
errorPaths: []
})
const [updatePermissionModalOpen, setUpdatePermissionModalOpen] =
useState(false)
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
useState(false)
const [deleteConfirmationModalMessage, setDeleteConfirmationModalMessage] =
useState('')
const [selectedPermission, setSelectedPermission] =
useState<PermissionResponse>()
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [pathFilter, setPathFilter] = useState<string[]>([])
const [principalFilter, setPrincipalFilter] = useState<string[]>([])
const [principalTypeFilter, setPrincipalTypeFilter] = useState<
PrincipalType[]
>([])
const [settingFilter, setSettingFilter] = useState<string[]>([])
const [permissions, setPermissions] = useState<PermissionResponse[]>([])
const [filteredPermissions, setFilteredPermissions] = useState<
PermissionResponse[]
>([])
const [filterApplied, setFilterApplied] = useState(false)
const fetchPermissions = useCallback(() => {
axios
.get(`/SASjsApi/permission`)
.then((res: any) => {
if (res.data?.length > 0) {
setPermissions(res.data)
}
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
}, [])
useEffect(() => {
fetchPermissions()
}, [fetchPermissions])
/**
* first find the permissions w.r.t each filter type
* take intersection of resultant arrays
*/
const applyFilter = () => {
setFilterModalOpen(false)
const uriFilteredPermissions =
pathFilter.length > 0
? permissions.filter((permission) =>
pathFilter.includes(permission.path)
)
: permissions
const principalFilteredPermissions =
principalFilter.length > 0
? permissions.filter((permission) => {
if (permission.user) {
return principalFilter.includes(permission.user.username)
}
if (permission.group) {
return principalFilter.includes(permission.group.name)
}
return false
})
: permissions
const principalTypeFilteredPermissions =
principalTypeFilter.length > 0
? permissions.filter((permission) => {
if (permission.user) {
return principalTypeFilter.includes(PrincipalType.User)
}
if (permission.group) {
return principalTypeFilter.includes(PrincipalType.Group)
}
return false
})
: permissions
const settingFilteredPermissions =
settingFilter.length > 0
? permissions.filter((permission) =>
settingFilter.includes(permission.setting)
)
: permissions
let filteredArray = uriFilteredPermissions.filter((permission) =>
principalFilteredPermissions.some(
(item) => item.permissionId === permission.permissionId
)
)
filteredArray = filteredArray.filter((permission) =>
principalTypeFilteredPermissions.some(
(item) => item.permissionId === permission.permissionId
)
)
filteredArray = filteredArray.filter((permission) =>
settingFilteredPermissions.some(
(item) => item.permissionId === permission.permissionId
)
)
setFilteredPermissions(filteredArray)
setFilterApplied(true)
}
const resetFilter = () => {
setFilterModalOpen(false)
setPathFilter([])
setPrincipalFilter([])
setSettingFilter([])
setFilteredPermissions([])
setFilterApplied(false)
}
const addPermission = async (
permissionsToAdd: RegisterPermissionPayload[],
permissionType: string,
principalType: string,
principal: string,
permissionSetting: string
) => {
setAddPermissionModalOpen(false)
setIsLoading(true)
const newAddedPermissions: PermissionResponse[] = []
const updatedPermissions: PermissionResponse[] = []
const errorPaths: string[] = []
const existingPermissions: PermissionResponse[] = []
const updatingPermissions: PermissionResponse[] = []
const newPermissions: RegisterPermissionPayload[] = []
permissionsToAdd.forEach((permission) => {
const existingPermission = findExistingPermission(permissions, permission)
if (existingPermission) {
existingPermissions.push(existingPermission)
return
}
const updatingPermission = findUpdatingPermission(permissions, permission)
if (updatingPermission) {
updatingPermissions.push(updatingPermission)
return
}
newPermissions.push(permission)
})
for (const permission of newPermissions) {
await axios
.post('/SASjsApi/permission', permission)
.then((res) => {
newAddedPermissions.push(res.data)
})
.catch((error) => {
errorPaths.push(permission.path)
})
}
for (const permission of updatingPermissions) {
await axios
.patch(`/SASjsApi/permission/${permission.permissionId}`, {
setting: permission.setting === 'Grant' ? 'Deny' : 'Grant'
})
.then((res) => {
updatedPermissions.push(res.data)
})
.catch((error) => {
errorPaths.push(permission.path)
})
}
fetchPermissions()
setIsLoading(false)
setPermissionResponsePayload({
permissionType,
principalType,
principal,
permissionSetting,
existingPermissions,
updatedPermissions,
newAddedPermissions,
errorPaths
})
setOpenPermissionResponseModal(true)
}
const handleUpdatePermissionClick = (permission: PermissionResponse) => {
setSelectedPermission(permission)
setUpdatePermissionModalOpen(true)
}
const updatePermission = (setting: string) => {
setUpdatePermissionModalOpen(false)
setIsLoading(true)
axios
.patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, {
setting
})
.then((res: any) => {
fetchPermissions()
setSnackbarMessage('Permission updated!')
setSnackbarSeverity(AlertSeverityType.Success)
setOpenSnackbar(true)
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => {
setIsLoading(false)
setSelectedPermission(undefined)
})
}
const handleDeletePermissionClick = (permission: PermissionResponse) => {
setSelectedPermission(permission)
setDeleteConfirmationModalOpen(true)
setDeleteConfirmationModalMessage(
'Are you sure you want to delete this permission?'
)
}
const deletePermission = () => {
setDeleteConfirmationModalOpen(false)
setIsLoading(true)
axios
.delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`)
.then((res: any) => {
fetchPermissions()
setSnackbarMessage('Permission deleted!')
setSnackbarSeverity(AlertSeverityType.Success)
setOpenSnackbar(true)
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => {
setIsLoading(false)
setSelectedPermission(undefined)
})
}
return isLoading ? (
<CircularProgress
style={{ position: 'absolute', left: '50%', top: '50%' }}
/>
) : (
<Box className="permissions-page">
<Grid container direction="column" spacing={1}>
<BootstrapGridItem item xs={12}>
<Paper elevation={3} sx={{ display: 'flex' }}>
<Tooltip title="Filter Permissions">
<IconButton onClick={() => setFilterModalOpen(true)}>
<FilterListIcon />
</IconButton>
</Tooltip>
{appContext.isAdmin && (
<Tooltip
sx={{ marginLeft: 'auto' }}
title="Add Permission"
placement="bottom-end"
>
<IconButton onClick={() => setAddPermissionModalOpen(true)}>
<AddIcon />
</IconButton>
</Tooltip>
)}
</Paper>
</BootstrapGridItem>
<BootstrapGridItem item xs={12}>
<PermissionTable
permissions={filterApplied ? filteredPermissions : permissions}
handleUpdatePermissionClick={handleUpdatePermissionClick}
handleDeletePermissionClick={handleDeletePermissionClick}
/>
</BootstrapGridItem>
</Grid>
<BootstrapSnackbar
open={openSnackbar}
setOpen={setOpenSnackbar}
message={snackbarMessage}
severity={snackbarSeverity}
/>
<Modal
open={openModal}
setOpen={setOpenModal}
title={modalTitle}
payload={modalPayload}
/>
<PermissionFilterModal
open={filterModalOpen}
handleOpen={setFilterModalOpen}
permissions={permissions}
pathFilter={pathFilter}
setPathFilter={setPathFilter}
principalFilter={principalFilter}
setPrincipalFilter={setPrincipalFilter}
principalTypeFilter={principalTypeFilter}
setPrincipalTypeFilter={setPrincipalTypeFilter}
settingFilter={settingFilter}
setSettingFilter={setSettingFilter}
applyFilter={applyFilter}
resetFilter={resetFilter}
/>
<AddPermissionModal
open={addPermissionModalOpen}
handleOpen={setAddPermissionModalOpen}
addPermission={addPermission}
/>
<PermissionResponseModal
open={openPermissionResponseModal}
setOpen={setOpenPermissionResponseModal}
payload={permissionResponsePayload}
/>
<UpdatePermissionModal
open={updatePermissionModalOpen}
handleOpen={setUpdatePermissionModalOpen}
permission={selectedPermission}
updatePermission={updatePermission}
/>
<DeleteConfirmationModal
open={deleteConfirmationModalOpen}
setOpen={setDeleteConfirmationModalOpen}
message={deleteConfirmationModalMessage}
_delete={deletePermission}
/>
</Box>
)
}
export default Permission
type PermissionTableProps = {
permissions: PermissionResponse[]
handleUpdatePermissionClick: (permission: PermissionResponse) => void
handleDeletePermissionClick: (permission: PermissionResponse) => void
}
const PermissionTable = ({
permissions,
handleUpdatePermissionClick,
handleDeletePermissionClick
}: PermissionTableProps) => {
const appContext = useContext(AppContext)
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }}>
<TableHead sx={{ background: 'rgb(0,0,0, 0.3)' }}>
<TableRow>
<BootstrapTableCell>Path</BootstrapTableCell>
<BootstrapTableCell>Permission Type</BootstrapTableCell>
<BootstrapTableCell>Principal</BootstrapTableCell>
<BootstrapTableCell>Principal Type</BootstrapTableCell>
<BootstrapTableCell>Setting</BootstrapTableCell>
{appContext.isAdmin && (
<BootstrapTableCell>Action</BootstrapTableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{permissions.map((permission) => (
<TableRow key={permission.permissionId}>
<BootstrapTableCell>{permission.path}</BootstrapTableCell>
<BootstrapTableCell>{permission.type}</BootstrapTableCell>
<BootstrapTableCell>
{displayPrincipal(permission)}
</BootstrapTableCell>
<BootstrapTableCell>
{displayPrincipalType(permission)}
</BootstrapTableCell>
<BootstrapTableCell>{permission.setting}</BootstrapTableCell>
{appContext.isAdmin && (
<BootstrapTableCell>
<Tooltip title="Edit Permission">
<IconButton
onClick={() => handleUpdatePermissionClick(permission)}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete Permission">
<IconButton
color="error"
onClick={() => handleDeletePermissionClick(permission)}
>
<DeleteForeverIcon />
</IconButton>
</Tooltip>
</BootstrapTableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}
const displayPrincipal = (permission: PermissionResponse) => {
if (permission.user) return permission.user.username
if (permission.group) return <DisplayGroup group={permission.group} />
}
type DisplayGroupProps = {
group: GroupDetailsResponse
}
const DisplayGroup = ({ group }: DisplayGroupProps) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const handlePopoverOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handlePopoverClose = () => {
setAnchorEl(null)
}
const open = Boolean(anchorEl)
return (
<div>
<Typography
aria-owns={open ? 'mouse-over-popover' : undefined}
aria-haspopup="true"
onMouseEnter={handlePopoverOpen}
onMouseLeave={handlePopoverClose}
>
{group.name}
</Typography>
<Popover
id="mouse-over-popover"
sx={{
pointerEvents: 'none'
}}
open={open}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left'
}}
onClose={handlePopoverClose}
disableRestoreFocus
>
<Typography sx={{ p: 1 }} variant="h6" component="div">
Group Members
</Typography>
{group.users.map((user, index) => (
<Typography key={index} sx={{ p: 1 }} component="li">
{user.username}
</Typography>
))}
</Popover>
</div>
)
}
const displayPrincipalType = (permission: PermissionResponse) => {
if (permission.user) return PrincipalType.User
if (permission.group) return PrincipalType.Group
}

View File

@@ -0,0 +1,154 @@
import React, { Dispatch, SetStateAction } from 'react'
import {
Button,
Grid,
Dialog,
DialogContent,
DialogActions,
TextField
} from '@mui/material'
import { styled } from '@mui/material/styles'
import Autocomplete from '@mui/material/Autocomplete'
import { PermissionResponse } from '../../utils/types'
import { BootstrapDialogTitle } from '../../components/dialogTitle'
import { PrincipalType } from './permission'
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
'& .MuiDialogContent-root': {
padding: theme.spacing(2)
},
'& .MuiDialogActions-root': {
padding: theme.spacing(1)
}
}))
type FilterModalProps = {
open: boolean
handleOpen: Dispatch<SetStateAction<boolean>>
permissions: PermissionResponse[]
pathFilter: string[]
setPathFilter: Dispatch<SetStateAction<string[]>>
principalFilter: string[]
setPrincipalFilter: Dispatch<SetStateAction<string[]>>
principalTypeFilter: PrincipalType[]
setPrincipalTypeFilter: Dispatch<SetStateAction<PrincipalType[]>>
settingFilter: string[]
setSettingFilter: Dispatch<SetStateAction<string[]>>
applyFilter: () => void
resetFilter: () => void
}
const PermissionFilterModal = ({
open,
handleOpen,
permissions,
pathFilter,
setPathFilter,
principalFilter,
setPrincipalFilter,
principalTypeFilter,
setPrincipalTypeFilter,
settingFilter,
setSettingFilter,
applyFilter,
resetFilter
}: FilterModalProps) => {
const paths = permissions
.map((permission) => permission.path)
.filter((uri, index, array) => array.indexOf(uri) === index)
// fetch all the principals from permissions array
let principals = permissions.map((permission) => {
if (permission.user) return permission.user.username
if (permission.group) return permission.group.name
return ''
})
// removes empty strings
principals = principals.filter((principal) => principal !== '')
// removes the duplicates
principals = principals.filter(
(principal, index, array) => array.indexOf(principal) === index
)
return (
<BootstrapDialog onClose={() => handleOpen(false)} open={open}>
<BootstrapDialogTitle
id="permission-filter-dialog-title"
handleOpen={handleOpen}
>
Permission Filter
</BootstrapDialogTitle>
<DialogContent dividers>
<Grid container spacing={1}>
<Grid item xs={12}>
<Autocomplete
multiple
options={paths}
filterSelectedOptions
value={pathFilter}
onChange={(event: any, newValue: string[]) => {
setPathFilter(newValue)
}}
renderInput={(params) => <TextField {...params} label="Paths" />}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
multiple
options={principals}
filterSelectedOptions
value={principalFilter}
onChange={(event: any, newValue: string[]) => {
setPrincipalFilter(newValue)
}}
renderInput={(params) => (
<TextField {...params} label="Principals" />
)}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
multiple
options={Object.values(PrincipalType)}
filterSelectedOptions
value={principalTypeFilter}
onChange={(event: any, newValue: PrincipalType[]) => {
setPrincipalTypeFilter(newValue)
}}
renderInput={(params) => (
<TextField {...params} label="Principal Type" />
)}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
multiple
options={['Grant', 'Deny']}
filterSelectedOptions
value={settingFilter}
onChange={(event: any, newValue: string[]) => {
setSettingFilter(newValue)
}}
renderInput={(params) => (
<TextField {...params} label="Settings" />
)}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button variant="outlined" color="error" onClick={resetFilter}>
Reset
</Button>
<Button variant="outlined" onClick={applyFilter}>
Apply
</Button>
</DialogActions>
</BootstrapDialog>
)
}
export default PermissionFilterModal

View File

@@ -0,0 +1,84 @@
import React, { useState, Dispatch, SetStateAction, useEffect } from 'react'
import {
Button,
Grid,
Dialog,
DialogContent,
DialogActions,
TextField
} from '@mui/material'
import { styled } from '@mui/material/styles'
import Autocomplete from '@mui/material/Autocomplete'
import { BootstrapDialogTitle } from '../../components/dialogTitle'
import { PermissionResponse } from '../../utils/types'
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
'& .MuiDialogContent-root': {
padding: theme.spacing(2)
},
'& .MuiDialogActions-root': {
padding: theme.spacing(1)
}
}))
type UpdatePermissionModalProps = {
open: boolean
handleOpen: Dispatch<SetStateAction<boolean>>
permission: PermissionResponse | undefined
updatePermission: (setting: string) => void
}
const UpdatePermissionModal = ({
open,
handleOpen,
permission,
updatePermission
}: UpdatePermissionModalProps) => {
const [permissionSetting, setPermissionSetting] = useState('Grant')
useEffect(() => {
if (permission) setPermissionSetting(permission.setting)
}, [permission])
return (
<BootstrapDialog onClose={() => handleOpen(false)} open={open}>
<BootstrapDialogTitle
id="add-permission-dialog-title"
handleOpen={handleOpen}
>
Update Permission
</BootstrapDialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<Grid item xs={12}>
<Autocomplete
sx={{ width: 300 }}
options={['Grant', 'Deny']}
disableClearable
value={permissionSetting}
onChange={(event: any, newValue: string) =>
setPermissionSetting(newValue)
}
renderInput={(params) => (
<TextField {...params} label="Settings" />
)}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button
variant="outlined"
onClick={() => updatePermission(permissionSetting)}
disabled={permission?.setting === permissionSetting}
>
Update
</Button>
</DialogActions>
</BootstrapDialog>
)
}
export default UpdatePermissionModal

View File

@@ -0,0 +1,686 @@
import React, {
Dispatch,
SetStateAction,
useEffect,
useRef,
useState,
useContext
} from 'react'
import axios from 'axios'
import {
Backdrop,
Box,
Button,
CircularProgress,
FormControl,
IconButton,
Menu,
MenuItem,
Paper,
Select,
SelectChangeEvent,
Tab,
Tooltip,
Typography
} from '@mui/material'
import { styled } from '@mui/material/styles'
import {
RocketLaunch,
MoreVert,
Save,
SaveAs,
Difference,
Edit
} from '@mui/icons-material'
import Editor, {
MonacoDiffEditor,
DiffEditorDidMount,
EditorDidMount,
monaco
} from 'react-monaco-editor'
import { TabContext, TabList, TabPanel } from '@mui/lab'
import { AppContext, RunTimeType } from '../../context/appContext'
import FilePathInputModal from '../../components/filePathInputModal'
import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar'
import Modal from '../../components/modal'
import { usePrompt, useStateWithCallback } from '../../utils/hooks'
const StyledTabPanel = styled(TabPanel)(() => ({
padding: '10px'
}))
const StyledTab = styled(Tab)(() => ({
fontSize: '1rem',
color: 'gray',
'&.Mui-selected': {
color: 'black'
}
}))
type SASjsEditorProps = {
selectedFilePath: string
setSelectedFilePath: (filePath: string, refreshSideBar?: boolean) => void
tab: string
setTab: Dispatch<SetStateAction<string>>
}
const baseUrl = window.location.origin
const SASjsEditor = ({
selectedFilePath,
setSelectedFilePath,
tab,
setTab
}: SASjsEditorProps) => {
const appContext = useContext(AppContext)
const [isLoading, setIsLoading] = useState(false)
const [openModal, setOpenModal] = useState(false)
const [modalTitle, setModalTitle] = useState('')
const [modalPayload, setModalPayload] = useState('')
const [openSnackbar, setOpenSnackbar] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
const [snackbarSeverity, setSnackbarSeverity] = useState<AlertSeverityType>(
AlertSeverityType.Success
)
const [prevFileContent, setPrevFileContent] = useStateWithCallback('')
const [fileContent, setFileContent] = useState('')
const [log, setLog] = useState('')
const [ctrlPressed, setCtrlPressed] = useState(false)
const [webout, setWebout] = useState('')
const [runTimes, setRunTimes] = useState<string[]>([])
const [selectedRunTime, setSelectedRunTime] = useState('')
const [selectedFileExtension, setSelectedFileExtension] = useState('')
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
const [showDiff, setShowDiff] = useState(false)
const editorRef = useRef(null as any)
const handleEditorDidMount: EditorDidMount = (editor) => {
editorRef.current = editor
editor.focus()
editor.addAction({
// An unique identifier of the contributed action.
id: 'show-difference',
// A label of the action that will be presented to the user.
label: 'Show Differences',
// An optional array of keybindings for the action.
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyD],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1,
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: function (ed) {
setShowDiff(true)
}
})
}
const handleDiffEditorDidMount: DiffEditorDidMount = (diffEditor) => {
diffEditor.focus()
diffEditor.addCommand(monaco.KeyCode.Escape, function () {
setShowDiff(false)
})
}
usePrompt(
'Changes you made may not be saved.',
prevFileContent !== fileContent && !!selectedFilePath
)
useEffect(() => {
setRunTimes(Object.values(appContext.runTimes))
}, [appContext.runTimes])
useEffect(() => {
if (runTimes.length) setSelectedRunTime(runTimes[0])
}, [runTimes])
useEffect(() => {
if (selectedFilePath) {
setIsLoading(true)
setSelectedFileExtension(selectedFilePath.split('.').pop() ?? '')
axios
.get(`/SASjsApi/drive/file?_filePath=${selectedFilePath}`)
.then((res: any) => {
setPrevFileContent(res.data)
setFileContent(res.data)
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => setIsLoading(false))
} else {
const content = localStorage.getItem('fileContent') ?? ''
setFileContent(content)
}
setLog('')
setWebout('')
setTab('code')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFilePath])
useEffect(() => {
if (fileContent.length && !selectedFilePath) {
localStorage.setItem('fileContent', fileContent)
}
}, [fileContent, selectedFilePath])
useEffect(() => {
if (runTimes.includes(selectedFileExtension))
setSelectedRunTime(selectedFileExtension)
}, [selectedFileExtension, runTimes])
const handleTabChange = (_e: any, newValue: string) => {
setTab(newValue)
}
const getSelection = () => {
const editor = editorRef.current as any
const selection = editor?.getModel().getValueInRange(editor?.getSelection())
return selection ?? ''
}
const handleRunBtnClick = () => runCode(getSelection() || fileContent)
const runCode = (code: string) => {
setIsLoading(true)
axios
.post(`/SASjsApi/code/execute`, { code, runTime: selectedRunTime })
.then((res: any) => {
const parsedLog = res?.data?.log
.map((logLine: any) => logLine.line)
.join('\n')
setLog(parsedLog)
setWebout(`${res.data?._webout}`)
setTab('log')
// Scroll to bottom of log
const logElement = document.getElementById('log')
if (logElement) logElement.scrollTop = logElement.scrollHeight
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => setIsLoading(false))
}
const handleKeyDown = (event: any) => {
if (event.ctrlKey) {
if (event.key === 'v') {
setCtrlPressed(false)
}
if (event.key === 'Enter') runCode(getSelection() || fileContent)
if (!ctrlPressed) setCtrlPressed(true)
}
}
const handleKeyUp = (event: any) => {
if (!event.ctrlKey && ctrlPressed) setCtrlPressed(false)
}
const handleChangeRunTime = (event: SelectChangeEvent) => {
setSelectedRunTime(event.target.value as RunTimeType)
}
const handleFilePathInput = (filePath: string) => {
setOpenFilePathInputModal(false)
saveFile(filePath)
}
const saveFile = (filePath?: string) => {
setIsLoading(true)
if (filePath) {
filePath = filePath.startsWith('/') ? filePath : `/${filePath}`
}
const formData = new FormData()
const stringBlob = new Blob([fileContent], { type: 'text/plain' })
formData.append('file', stringBlob, 'filename.sas')
formData.append('filePath', filePath ?? selectedFilePath)
const axiosPromise = filePath
? axios.post('/SASjsApi/drive/file', formData)
: axios.patch('/SASjsApi/drive/file', formData)
axiosPromise
.then(() => {
if (filePath && fileContent === prevFileContent) {
// when fileContent and prevFileContent is same,
// callback function in setPrevFileContent method is not called
// because behind the scene useEffect hook is being used
// for calling callback function, and it's only fired when the
// new value is not equal to old value.
// So, we'll have to explicitly update the selected file path
setSelectedFilePath(filePath, true)
} else {
setPrevFileContent(fileContent, () => {
if (filePath) {
setSelectedFilePath(filePath, true)
}
})
}
setSnackbarMessage('File saved!')
setSnackbarSeverity(AlertSeverityType.Success)
setOpenSnackbar(true)
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => {
setIsLoading(false)
})
}
return (
<Box sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isLoading}
>
<CircularProgress color="inherit" />
</Backdrop>
{selectedFilePath && !runTimes.includes(selectedFileExtension) ? (
<Box sx={{ marginTop: '10px' }}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<FileMenu
showDiff={showDiff}
setShowDiff={setShowDiff}
prevFileContent={prevFileContent}
currentFileContent={fileContent}
selectedFilePath={selectedFilePath}
setOpenFilePathInputModal={setOpenFilePathInputModal}
saveFile={saveFile}
/>
</Box>
<Paper
sx={{
height: 'calc(100vh - 140px)',
padding: '10px',
margin: '0 24px',
overflow: 'auto',
position: 'relative'
}}
elevation={3}
>
{showDiff ? (
<MonacoDiffEditor
height="98%"
language={getLanguage(selectedFileExtension)}
original={prevFileContent}
value={fileContent}
editorDidMount={handleDiffEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => setFileContent(val)}
/>
) : (
<Editor
height="98%"
language={getLanguage(selectedFileExtension)}
value={fileContent}
editorDidMount={handleEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => setFileContent(val)}
/>
)}
</Paper>
</Box>
) : (
<TabContext value={tab}>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider',
background: 'white'
}}
>
<TabList onChange={handleTabChange} centered>
<StyledTab label="Code" value="code" />
<StyledTab label="Log" value="log" />
<StyledTab
label={
<Tooltip title="Displays content from the _webout fileref">
<Typography>Webout</Typography>
</Tooltip>
}
value="webout"
/>
</TabList>
</Box>
<StyledTabPanel sx={{ paddingBottom: 0 }} value="code">
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<RunMenu
fileContent={fileContent}
prevFileContent={prevFileContent}
selectedFilePath={selectedFilePath}
selectedRunTime={selectedRunTime}
runTimes={runTimes}
handleChangeRunTime={handleChangeRunTime}
handleRunBtnClick={handleRunBtnClick}
/>
<FileMenu
showDiff={showDiff}
setShowDiff={setShowDiff}
prevFileContent={prevFileContent}
currentFileContent={fileContent}
selectedFilePath={selectedFilePath}
setOpenFilePathInputModal={setOpenFilePathInputModal}
saveFile={saveFile}
/>
</Box>
<Paper
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
sx={{
height: 'calc(100vh - 170px)',
padding: '10px',
overflow: 'auto',
position: 'relative'
}}
elevation={3}
>
{showDiff ? (
<MonacoDiffEditor
height="98%"
language={getLanguage(selectedFileExtension)}
original={prevFileContent}
value={fileContent}
editorDidMount={handleDiffEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => setFileContent(val)}
/>
) : (
<Editor
height="98%"
language={getLanguage(selectedFileExtension)}
value={fileContent}
editorDidMount={handleEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => setFileContent(val)}
/>
)}
<p
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: -10,
textAlign: 'center',
fontSize: '13px'
}}
>
Press CTRL + ENTER to run code
</p>
</Paper>
</StyledTabPanel>
<StyledTabPanel value="log">
<div>
<h2>Log</h2>
<pre id="log" style={{ overflow: 'auto', height: '75vh' }}>
{log}
</pre>
</div>
</StyledTabPanel>
<StyledTabPanel value="webout">
<div>
<pre>{webout}</pre>
</div>
</StyledTabPanel>
</TabContext>
)}
<Modal
open={openModal}
setOpen={setOpenModal}
title={modalTitle}
payload={modalPayload}
/>
<BootstrapSnackbar
open={openSnackbar}
setOpen={setOpenSnackbar}
message={snackbarMessage}
severity={snackbarSeverity}
/>
<FilePathInputModal
open={openFilePathInputModal}
setOpen={setOpenFilePathInputModal}
saveFile={handleFilePathInput}
/>
</Box>
)
}
export default SASjsEditor
type RunMenuProps = {
selectedFilePath: string
fileContent: string
prevFileContent: string
selectedRunTime: string
runTimes: string[]
handleChangeRunTime: (event: SelectChangeEvent) => void
handleRunBtnClick: () => void
}
const RunMenu = ({
selectedFilePath,
fileContent,
prevFileContent,
selectedRunTime,
runTimes,
handleChangeRunTime,
handleRunBtnClick
}: RunMenuProps) => {
const launchProgram = () => {
window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${selectedFilePath}`)
}
return (
<>
<Tooltip title="CTRL+ENTER will also run code">
<Button
onClick={handleRunBtnClick}
sx={{
display: 'flex',
alignItems: 'center',
padding: '5px 5px',
minWidth: 'unset'
}}
>
<img
alt=""
draggable="false"
style={{ width: '25px' }}
src="/running-sas.png"
></img>
<span style={{ fontSize: '12px' }}>RUN</span>
</Button>
</Tooltip>
{selectedFilePath ? (
<Box sx={{ marginLeft: '10px' }}>
<Tooltip
title={
fileContent !== prevFileContent
? 'Save file before launching program'
: 'Launch program in new window'
}
>
<span>
<IconButton
disabled={fileContent !== prevFileContent}
onClick={launchProgram}
>
<RocketLaunch />
</IconButton>
</span>
</Tooltip>
</Box>
) : (
<Box sx={{ minWidth: '75px', marginLeft: '10px' }}>
<FormControl variant="standard">
<Select
labelId="run-time-select-label"
id="run-time-select"
value={selectedRunTime}
onChange={handleChangeRunTime}
>
{runTimes.map((runTime) => (
<MenuItem key={runTime} value={runTime}>
{runTime}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
)}
</>
)
}
type FileMenuProps = {
showDiff: boolean
setShowDiff: React.Dispatch<React.SetStateAction<boolean>>
prevFileContent: string
currentFileContent: string
selectedFilePath: string
setOpenFilePathInputModal: React.Dispatch<React.SetStateAction<boolean>>
saveFile: () => void
}
const FileMenu = ({
showDiff,
setShowDiff,
prevFileContent,
currentFileContent,
selectedFilePath,
setOpenFilePathInputModal,
saveFile
}: FileMenuProps) => {
const [anchorEl, setAnchorEl] = useState<
(EventTarget & HTMLButtonElement) | null
>(null)
const handleMenu = (
event?: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
if (event) setAnchorEl(event.currentTarget)
else setAnchorEl(null)
}
const handleDiffBtnClick = () => {
setAnchorEl(null)
setShowDiff(!showDiff)
}
const handleSaveAsBtnClick = () => {
setAnchorEl(null)
setOpenFilePathInputModal(true)
}
const handleSaveBtnClick = () => {
setAnchorEl(null)
saveFile()
}
return (
<>
<Tooltip title="Save File Menu">
<IconButton onClick={handleMenu}>
<MoreVert />
</IconButton>
</Tooltip>
<Menu
id="save-file-menu"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
keepMounted
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
open={!!anchorEl}
onClose={() => handleMenu()}
>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
onClick={handleDiffBtnClick}
variant="contained"
color="primary"
startIcon={showDiff ? <Edit /> : <Difference />}
>
{showDiff ? 'Edit' : 'Diff'}
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
onClick={handleSaveBtnClick}
variant="contained"
color="primary"
startIcon={<Save />}
disabled={
!selectedFilePath || prevFileContent === currentFileContent
}
>
Save
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
onClick={handleSaveAsBtnClick}
variant="contained"
color="primary"
startIcon={<SaveAs />}
>
Save As
</Button>
</MenuItem>
</Menu>
</>
)
}
const getLanguage = (extension: string) => {
if (extension === 'js') return 'javascript'
if (extension === 'ts') return 'typescript'
if (extension === 'md' || extension === 'mdx') return 'markdown'
return extension
}

View File

@@ -1,242 +1,104 @@
import React, { useEffect, useRef, useState, useContext } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import axios from 'axios'
import {
Box,
MenuItem,
FormControl,
Select,
SelectChangeEvent,
Button,
Paper,
Tab,
Tooltip
} from '@mui/material'
import { makeStyles } from '@mui/styles'
import Editor, { EditorDidMount } from 'react-monaco-editor'
import { useLocation } from 'react-router-dom'
import { TabContext, TabList, TabPanel } from '@mui/lab'
import CssBaseline from '@mui/material/CssBaseline'
import Box from '@mui/material/Box'
import { AppContext, RunTimeType } from '../../context/appContext'
import { TreeNode } from '../../utils/types'
const useStyles = makeStyles(() => ({
root: {
fontSize: '1rem',
color: 'gray',
'&.Mui-selected': {
color: 'black'
}
},
subMenu: {
marginTop: '25px',
display: 'flex',
justifyContent: 'center'
},
runButton: {
display: 'flex',
alignItems: 'center',
padding: '5px 5px',
minWidth: 'unset'
}
}))
import SideBar from './sideBar'
import SASjsEditor from './editor'
const Studio = () => {
const appContext = useContext(AppContext)
const location = useLocation()
const [fileContent, setFileContent] = useState('')
const [log, setLog] = useState('')
const [ctrlPressed, setCtrlPressed] = useState(false)
const [webout, setWebout] = useState('')
const [tab, setTab] = useState('1')
const [runTimes, setRunTimes] = useState<string[]>([])
const [selectedRunTime, setSelectedRunTime] = useState('')
const [searchParams, setSearchParams] = useSearchParams()
const [selectedFilePath, setSelectedFilePath] = useState('')
const [directoryData, setDirectoryData] = useState<TreeNode | null>(null)
const [tab, setTab] = useState('code')
useEffect(() => {
setRunTimes(Object.values(appContext.runTimes))
}, [appContext.runTimes])
setSelectedFilePath(searchParams.get('filePath') ?? '')
}, [searchParams])
useEffect(() => {
if (runTimes.length) setSelectedRunTime(runTimes[0])
}, [runTimes])
const handleTabChange = (_e: any, newValue: string) => {
setTab(newValue)
}
const editorRef = useRef(null as any)
const handleEditorDidMount: EditorDidMount = (editor) => {
editor.focus()
editorRef.current = editor
}
const getSelection = () => {
const editor = editorRef.current as any
const selection = editor?.getModel().getValueInRange(editor?.getSelection())
return selection ?? ''
}
const handleRunBtnClick = () => runCode(getSelection() || fileContent)
const runCode = (code: string) => {
const fetchDirectoryData = useCallback(() => {
axios
.post(`/SASjsApi/code/execute`, { code, runTime: selectedRunTime })
.get(`/SASjsApi/drive/fileTree`)
.then((res: any) => {
const parsedLog = res?.data?.log
.map((logLine: any) => logLine.line)
.join('\n')
setLog(parsedLog)
setWebout(`${res.data?._webout}`)
setTab('2')
// Scroll to bottom of log
window.scrollTo(0, document.body.scrollHeight)
if (res.data && res.data?.status === 'success') {
setDirectoryData(res.data.tree)
}
})
.catch((err) => {
console.log(err)
})
.catch((err) => console.log(err))
}
const handleKeyDown = (event: any) => {
if (event.ctrlKey) {
if (event.key === 'v') {
setCtrlPressed(false)
}
if (event.key === 'Enter') runCode(getSelection() || fileContent)
if (!ctrlPressed) setCtrlPressed(true)
}
}
const handleKeyUp = (event: any) => {
if (!event.ctrlKey && ctrlPressed) setCtrlPressed(false)
}
const handleChangeRunTime = (event: SelectChangeEvent) => {
setSelectedRunTime(event.target.value as RunTimeType)
}
useEffect(() => {
const content = localStorage.getItem('fileContent') ?? ''
setFileContent(content)
}, [])
useEffect(() => {
if (fileContent.length) {
localStorage.setItem('fileContent', fileContent)
fetchDirectoryData()
}, [fetchDirectoryData])
const handleSelect = (filePath: string, refreshSideBar?: boolean) => {
setSearchParams({ filePath })
if (refreshSideBar) fetchDirectoryData()
}
const removeFileFromTree = (path: string) => {
if (directoryData) {
const newTree = JSON.parse(JSON.stringify(directoryData)) as TreeNode
findAndRemoveNode(newTree, newTree, path)
setDirectoryData(newTree)
}
}, [fileContent])
}
useEffect(() => {
const params = new URLSearchParams(location.search)
const programPath = params.get('_program')
const findAndRemoveNode = (
node: TreeNode,
parentNode: TreeNode,
path: string
) => {
if (node.relativePath === path) {
removeNodeFromParent(parentNode, path)
// reset selected file path and file path query param
if (
node.relativePath === selectedFilePath ||
selectedFilePath.startsWith(node.relativePath)
)
setSearchParams({})
return true
}
if (Array.isArray(node.children)) {
for (let i = 0; i < node.children.length; i++) {
if (findAndRemoveNode(node.children[i], node, path)) return
}
}
}
if (programPath?.length)
axios
.get(`/SASjsApi/drive/file?filePath=${programPath}`)
.then((res: any) => setFileContent(res.data.fileContent))
.catch((err) => console.log(err))
}, [location.search])
const classes = useStyles()
const removeNodeFromParent = (parent: TreeNode, path: string) => {
const index = parent.children.findIndex(
(node) => node.relativePath === path
)
if (index !== -1) {
parent.children.splice(index, 1)
}
}
return (
<Box
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}
>
<TabContext value={tab}>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider'
}}
style={{ position: 'fixed', background: 'white', width: '100%' }}
>
<TabList onChange={handleTabChange} centered>
<Tab className={classes.root} label="Code" value="1" />
<Tab className={classes.root} label="Log" value="2" />
<Tooltip title="Displays content from the _webout fileref">
<Tab className={classes.root} label="Webout" value="3" />
</Tooltip>
</TabList>
</Box>
<TabPanel sx={{ paddingBottom: 0 }} value="1">
<div className={classes.subMenu}>
<Tooltip title="CTRL+ENTER will also run SAS code">
<Button onClick={handleRunBtnClick} className={classes.runButton}>
<img
alt=""
draggable="false"
style={{ width: '25px' }}
src="/running-sas.png"
></img>
<span style={{ fontSize: '12px' }}>RUN</span>
</Button>
</Tooltip>
<Box sx={{ minWidth: '75px', marginLeft: '10px' }}>
<FormControl variant="standard">
<Select
labelId="run-time-select-label"
id="run-time-select"
value={selectedRunTime}
onChange={handleChangeRunTime}
>
{runTimes.map((runTime) => (
<MenuItem key={runTime} value={runTime}>
{runTime}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</div>
<Paper
sx={{
height: 'calc(100vh - 170px)',
padding: '10px',
overflow: 'auto',
position: 'relative'
}}
elevation={3}
>
<Editor
height="98%"
language="sas"
value={fileContent}
editorDidMount={handleEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => {
if (val) setFileContent(val)
}}
/>
<p
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: -10,
textAlign: 'center',
fontSize: '13px'
}}
>
Press CTRL + ENTER to run SAS code
</p>
</Paper>
</TabPanel>
<TabPanel value="2">
<div style={{ marginTop: '50px' }}>
<h2>SAS Log</h2>
<pre>{log}</pre>
</div>
</TabPanel>
<TabPanel value="3">
<div style={{ marginTop: '50px' }}>
<pre>{webout}</pre>
</div>
</TabPanel>
</TabContext>
<Box sx={{ display: 'flex' }}>
<CssBaseline />
{tab === 'code' && (
<SideBar
selectedFilePath={selectedFilePath}
directoryData={directoryData}
handleSelect={handleSelect}
removeFileFromTree={removeFileFromTree}
refreshSideBar={fetchDirectoryData}
/>
)}
<SASjsEditor
selectedFilePath={selectedFilePath}
setSelectedFilePath={handleSelect}
tab={tab}
setTab={setTab}
/>
</Box>
)
}

View File

@@ -0,0 +1,267 @@
import React, { useState, useMemo } from 'react'
import axios from 'axios'
import {
Backdrop,
Box,
Paper,
CircularProgress,
Drawer,
Toolbar,
IconButton
} from '@mui/material'
import { FolderOpen } from '@mui/icons-material'
import TreeView from '../../components/tree'
import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar'
import Modal from '../../components/modal'
import { TreeNode } from '../../utils/types'
const drawerWidth = '15%'
type Props = {
selectedFilePath: string
directoryData: TreeNode | null
handleSelect: (filePath: string) => void
removeFileFromTree: (filePath: string) => void
refreshSideBar: () => void
}
const SideBar = ({
selectedFilePath,
directoryData,
handleSelect,
removeFileFromTree,
refreshSideBar
}: Props) => {
const [isLoading, setIsLoading] = useState(false)
const [openModal, setOpenModal] = useState(false)
const [modalTitle, setModalTitle] = useState('')
const [modalPayload, setModalPayload] = useState('')
const [openSnackbar, setOpenSnackbar] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
const [snackbarSeverity, setSnackbarSeverity] = useState<AlertSeverityType>(
AlertSeverityType.Success
)
const [mobileOpen, setMobileOpen] = React.useState(false)
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen)
}
const handleFileSelect = (filePath: string) => {
setMobileOpen(false)
handleSelect(filePath)
}
const defaultExpanded = useMemo(() => {
const splittedPath = selectedFilePath.split('/')
const arr = ['']
let nodeId = ''
splittedPath.forEach((path) => {
if (path !== '') {
nodeId += '/' + path
arr.push(nodeId)
}
})
return arr
}, [selectedFilePath])
const deleteNode = (path: string, isFolder: boolean) => {
setIsLoading(true)
const axiosPromise = axios.delete(
`/SASjsApi/drive/${
isFolder ? `folder?_folderPath=${path}` : `file?_filePath=${path}`
}`
)
axiosPromise
.then(() => {
removeFileFromTree(path)
setSnackbarMessage('Deleted!')
setSnackbarSeverity(AlertSeverityType.Success)
setOpenSnackbar(true)
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => setIsLoading(false))
}
const addFile = (filePath: string) => {
const formData = new FormData()
const stringBlob = new Blob([''], { type: 'text/plain' })
formData.append('file', stringBlob)
formData.append('filePath', filePath)
setIsLoading(true)
axios
.post('/SASjsApi/drive/file', formData)
.then(() => {
setSnackbarMessage('File added!')
setSnackbarSeverity(AlertSeverityType.Success)
setOpenSnackbar(true)
refreshSideBar()
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => setIsLoading(false))
}
const addFolder = (folderPath: string) => {
setIsLoading(true)
axios
.post('/SASjsApi/drive/folder', { folderPath })
.then(() => {
setSnackbarMessage('Folder added!')
setSnackbarSeverity(AlertSeverityType.Success)
setOpenSnackbar(true)
refreshSideBar()
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => setIsLoading(false))
}
const rename = (oldPath: string, newPath: string) => {
setIsLoading(true)
axios
.post('/SASjsApi/drive/rename', { oldPath, newPath })
.then(() => {
setSnackbarMessage('Successfully Renamed')
setSnackbarSeverity(AlertSeverityType.Success)
setOpenSnackbar(true)
if (oldPath === selectedFilePath) handleSelect(newPath)
else if (selectedFilePath.startsWith(oldPath))
handleSelect(selectedFilePath.replace(oldPath, newPath))
refreshSideBar()
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => setIsLoading(false))
}
const drawer = (
<div>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isLoading}
>
<CircularProgress color="inherit" />
</Backdrop>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
{directoryData && (
<TreeView
node={directoryData}
selectedFilePath={selectedFilePath}
handleSelect={handleFileSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
rename={rename}
defaultExpanded={defaultExpanded}
/>
)}
</Box>
<BootstrapSnackbar
open={openSnackbar}
setOpen={setOpenSnackbar}
message={snackbarMessage}
severity={snackbarSeverity}
/>
<Modal
open={openModal}
setOpen={setOpenModal}
title={modalTitle}
payload={modalPayload}
/>
</div>
)
return (
<>
<Box
component={Paper}
sx={{
margin: '5px',
height: '97vh',
paddingTop: '45px',
display: 'flex',
alignItems: 'flex-start'
}}
>
<IconButton
color="inherit"
size="large"
aria-label="open drawer"
edge="start"
onClick={handleDrawerToggle}
sx={{ left: '5px', display: { md: 'none' } }}
>
<FolderOpen />
</IconButton>
</Box>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{
keepMounted: true // Better open performance on mobile.
}}
sx={{
display: { xs: 'block', md: 'none' },
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: 240,
boxSizing: 'border-box'
}
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: {
width: drawerWidth,
boxSizing: 'border-box'
}
}}
>
{drawer}
</Drawer>
</>
)
}
export default SideBar

View File

@@ -29,6 +29,8 @@ interface AppContextProps {
setUsername: Dispatch<SetStateAction<string>> | null
displayName: string
setDisplayName: Dispatch<SetStateAction<string>> | null
isAdmin: boolean
setIsAdmin: Dispatch<SetStateAction<boolean>> | null
mode: ModeType
runTimes: RunTimeType[]
logout: (() => void) | null
@@ -44,6 +46,8 @@ export const AppContext = createContext<AppContextProps>({
setUsername: null,
displayName: '',
setDisplayName: null,
isAdmin: false,
setIsAdmin: null,
mode: ModeType.Server,
runTimes: [],
logout: null
@@ -56,6 +60,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
const [userId, setUserId] = useState(0)
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [mode, setMode] = useState(ModeType.Server)
const [runTimes, setRunTimes] = useState<RunTimeType[]>([])
@@ -70,11 +75,23 @@ const AppContextProvider = (props: { children: ReactNode }) => {
setUserId(data.id)
setUsername(data.username)
setDisplayName(data.displayName)
setIsAdmin(data.isAdmin)
setLoggedIn(true)
})
.catch(() => {
setLoggedIn(false)
axios.get('/') // get CSRF TOKEN
// get CSRF TOKEN and set cookie
axios
.get('/')
.then((res) => res.data)
.then((data: string) => {
const result =
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/.exec(
data
)?.[1]
if (result) document.cookie = result
})
})
axios
@@ -107,6 +124,8 @@ const AppContextProvider = (props: { children: ReactNode }) => {
setUsername,
displayName,
setDisplayName,
isAdmin,
setIsAdmin,
mode,
runTimes,
logout

View File

@@ -13,8 +13,27 @@ code {
}
.main {
margin-top: 50px;
margin: 50px 10px 0 10px;
display: flex;
flex-direction: column;
align-items: center;
}
.permissions-page {
display: flex;
flex-direction: column;
padding: '5px 10px';
margin-top: '10px';
}
.tree-item-label {
display: flex;
}
.tree-item-label.selected {
background: lightgoldenrodyellow;
}
.tree-item-label:hover {
background: lightgray;
}

59
web/src/utils/helper.ts Normal file
View File

@@ -0,0 +1,59 @@
import { PermissionResponse, RegisterPermissionPayload } from './types'
export const findExistingPermission = (
existingPermissions: PermissionResponse[],
newPermission: RegisterPermissionPayload
) => {
for (const permission of existingPermissions) {
if (
permission.user?.id === newPermission.principalId &&
hasSameCombination(permission, newPermission)
)
return permission
if (
permission.group?.groupId === newPermission.principalId &&
hasSameCombination(permission, newPermission)
)
return permission
}
return null
}
export const findUpdatingPermission = (
existingPermissions: PermissionResponse[],
newPermission: RegisterPermissionPayload
) => {
for (const permission of existingPermissions) {
if (
permission.user?.id === newPermission.principalId &&
hasDifferentSetting(permission, newPermission)
)
return permission
if (
permission.group?.groupId === newPermission.principalId &&
hasDifferentSetting(permission, newPermission)
)
return permission
}
return null
}
const hasSameCombination = (
existingPermission: PermissionResponse,
newPermission: RegisterPermissionPayload
) =>
existingPermission.path === newPermission.path &&
existingPermission.type === newPermission.type &&
existingPermission.setting === newPermission.setting
const hasDifferentSetting = (
existingPermission: PermissionResponse,
newPermission: RegisterPermissionPayload
) =>
existingPermission.path === newPermission.path &&
existingPermission.type === newPermission.type &&
existingPermission.setting !== newPermission.setting

View File

@@ -0,0 +1,2 @@
export * from './usePrompt'
export * from './useStateWithCallback'

View File

@@ -0,0 +1,36 @@
import { useEffect, useCallback, useContext } from 'react'
import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom'
import { History, Blocker, Transition } from 'history'
const useBlocker = (blocker: Blocker, when = true) => {
const navigator = useContext(NavigationContext).navigator as History
useEffect(() => {
if (!when) return
const unblock = navigator.block((tx: Transition) => {
const autoUnblockingTx = {
...tx,
retry() {
unblock()
tx.retry()
}
}
blocker(autoUnblockingTx)
})
return unblock
}, [navigator, blocker, when])
}
export const usePrompt = (message: string, when = true) => {
const blocker = useCallback(
(tx) => {
if (window.confirm(message)) tx.retry()
},
[message]
)
useBlocker(blocker, when)
}

View File

@@ -0,0 +1,27 @@
import { useState, useEffect, useRef } from 'react'
export const useStateWithCallback = <T>(
initialValue: T
): [T, (newValue: T, callback?: () => void) => void] => {
const callbackRef = useRef<any>(null)
const [value, setValue] = useState(initialValue)
useEffect(() => {
if (typeof callbackRef.current === 'function') {
callbackRef.current()
callbackRef.current = null
}
}, [value])
const setValueWithCallback = (newValue: T, callback?: () => void) => {
callbackRef.current = callback
setValue(newValue)
}
return [value, setValueWithCallback]
}
export default useStateWithCallback

Some files were not shown because too many files have changed in this diff Show More