mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5be1be21 | ||
|
|
d90fa9e5dd | ||
| d99fdd1ec7 | |||
|
|
399b5edad0 | ||
|
|
1dbc12e96b | ||
| e215958b8b | |||
| 9227cd449d | |||
| c67d3ee2f1 | |||
| 6ef40b954a | |||
|
|
0d913baff1 | ||
|
|
3671736c3d | ||
| 34cd84d8a9 | |||
|
|
f7fcc7741a | ||
|
|
18052fdbf6 | ||
|
|
5966016853 | ||
|
|
87c03c5f8d | ||
| 7a162eda8f | |||
| 754704bca8 | |||
|
|
77f8d30baf | ||
|
|
78bea7c154 | ||
|
|
9c3b155c12 | ||
|
|
98e501334f | ||
|
|
bbfd53e79e | ||
| 254bc07da7 | |||
| f978814ca7 | |||
| 68515f95a6 | |||
| d3a516c36e | |||
| c3e3befc17 | |||
|
|
275de9478e | ||
|
|
1a3ef62cb2 | ||
|
|
9eb5f3ca4d | ||
|
|
916947dffa | ||
| 79b7827b7c | |||
| 37e1aa9b61 | |||
| 7e504008b7 | |||
| 5d5a9d3788 | |||
|
|
7c79d6479c | ||
|
|
3e635f422a | ||
|
|
77db14c690 | ||
| b7dff341f0 | |||
| 8a3054e19a | |||
|
|
a531de2adb | ||
|
|
c458d94493 | ||
| 706e228a8e | |||
| 7681722e5a | |||
| 8de032b543 | |||
|
|
998ef213e9 | ||
|
|
f8b0f98678 | ||
| 9640f65264 | |||
| c574b42235 | |||
| 468d1a929d | |||
| 7cdffe30e3 | |||
| 3b1fcb937d | |||
| 3c987c61dd | |||
| 0a780697da | |||
| 83d819df53 | |||
|
|
95df2b21d6 | ||
|
|
accdf914f1 | ||
| 15bdd2d7f0 | |||
| 2ce947d216 | |||
| ce2114e3f6 | |||
| 6c7550286b | |||
| 2360e104bd | |||
| 420a61a5a6 | |||
| 04e0f9efe3 | |||
| 99172cd9ed | |||
| 57daad0c26 | |||
| cc1e4543fc | |||
| 03cb89d14f | |||
| 72140d73c2 | |||
| efcefd2a42 | |||
| 06d7c91fc3 | |||
| 7010a6a120 | |||
| fdcaba9d56 | |||
| 48688a6547 | |||
| 0ce94a553e | |||
| 941917e508 | |||
|
|
5706371ffd | ||
|
|
ce5218a227 | ||
|
|
8b62755f39 | ||
|
|
cb84c3ebbb | ||
|
|
526402fd73 | ||
| 177675bc89 | |||
| 721165ff12 | |||
| 08e0c61e0f | |||
|
|
1b234eb2b1 | ||
|
|
ef25eec11f | ||
| 3e53f70928 | |||
| 0f19384999 | |||
| 63dd6813c0 | |||
| 299512135d | |||
| 6c35412d2f | |||
| 27410bc32b | |||
| 849b2dd468 | |||
|
|
a1a182698e | ||
|
|
4be692b24b | ||
|
|
d2ddd8aaca | ||
| 30d7a65358 | |||
| 5e930f14d2 | |||
| 9bc68b1cdc |
142
CHANGELOG.md
142
CHANGELOG.md
@@ -1,3 +1,145 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ 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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path'
|
||||
import express, { ErrorRequestHandler } from 'express'
|
||||
import csrf from 'csurf'
|
||||
import csrf, { CookieOptions } from 'csurf'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
@@ -32,9 +32,10 @@ const app = express()
|
||||
|
||||
const { PROTOCOL } = process.env
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Route, Tags, Example, Get } from 'tsoa'
|
||||
import { getAuthorizedRoutes } from '../utils'
|
||||
export interface AuthorizedRoutesResponse {
|
||||
URIs: string[]
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export interface InfoResponse {
|
||||
@@ -42,16 +42,16 @@ export class InfoController {
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get authorized routes.
|
||||
* @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>({
|
||||
URIs: ['/AppStream', '/SASjsApi/stp/execute']
|
||||
paths: ['/AppStream', '/SASjsApi/stp/execute']
|
||||
})
|
||||
@Get('/authorizedRoutes')
|
||||
public authorizedRoutes(): AuthorizedRoutesResponse {
|
||||
const response = {
|
||||
URIs: getAuthorizedRoutes()
|
||||
paths: getAuthorizedRoutes()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -102,7 +102,10 @@ ${autoExecContent}`
|
||||
'-AUTOEXEC',
|
||||
autoExecPath,
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
||||
isWindows() ? '-icon' : '',
|
||||
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(() => {
|
||||
|
||||
@@ -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}';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import express from 'express'
|
||||
import {
|
||||
Security,
|
||||
Route,
|
||||
@@ -8,7 +9,8 @@ import {
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body
|
||||
Body,
|
||||
Request
|
||||
} from 'tsoa'
|
||||
|
||||
import Permission from '../model/Permission'
|
||||
@@ -17,12 +19,16 @@ 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 PermissionSetting {
|
||||
export enum PermissionSettingForRoute {
|
||||
grant = 'Grant',
|
||||
deny = 'Deny'
|
||||
}
|
||||
@@ -32,12 +38,17 @@ interface RegisterPermissionPayload {
|
||||
* Name of affected resource
|
||||
* @example "/SASjsApi/code/execute"
|
||||
*/
|
||||
uri: string
|
||||
path: string
|
||||
/**
|
||||
* Type of affected resource
|
||||
* @example "Route"
|
||||
*/
|
||||
type: PermissionType
|
||||
/**
|
||||
* The indication of whether (and to what extent) access is provided
|
||||
* @example "Grant"
|
||||
*/
|
||||
setting: PermissionSetting
|
||||
setting: PermissionSettingForRoute
|
||||
/**
|
||||
* Indicates the type of principal
|
||||
* @example "user"
|
||||
@@ -55,12 +66,13 @@ interface UpdatePermissionPayload {
|
||||
* The indication of whether (and to what extent) access is provided
|
||||
* @example "Grant"
|
||||
*/
|
||||
setting: PermissionSetting
|
||||
setting: PermissionSettingForRoute
|
||||
}
|
||||
|
||||
export interface PermissionDetailsResponse {
|
||||
permissionId: number
|
||||
uri: string
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
user?: UserResponse
|
||||
group?: GroupDetailsResponse
|
||||
@@ -71,13 +83,17 @@ export interface PermissionDetailsResponse {
|
||||
@Tags('Permission')
|
||||
export class PermissionController {
|
||||
/**
|
||||
* @summary Get list of all permissions (uri, setting and userDetail).
|
||||
* 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,
|
||||
uri: '/SASjsApi/code/execute',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
@@ -88,7 +104,8 @@ export class PermissionController {
|
||||
},
|
||||
{
|
||||
permissionId: 124,
|
||||
uri: '/SASjsApi/code/execute',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
group: {
|
||||
groupId: 1,
|
||||
@@ -100,8 +117,10 @@ export class PermissionController {
|
||||
}
|
||||
])
|
||||
@Get('/')
|
||||
public async getAllPermissions(): Promise<PermissionDetailsResponse[]> {
|
||||
return getAllPermissions()
|
||||
public async getAllPermissions(
|
||||
@Request() request: express.Request
|
||||
): Promise<PermissionDetailsResponse[]> {
|
||||
return getAllPermissions(request)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +129,8 @@ export class PermissionController {
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
uri: '/SASjsApi/code/execute',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
@@ -133,7 +153,8 @@ export class PermissionController {
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
uri: '/SASjsApi/code/execute',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
@@ -161,33 +182,43 @@ export class PermissionController {
|
||||
}
|
||||
}
|
||||
|
||||
const getAllPermissions = async (): Promise<PermissionDetailsResponse[]> =>
|
||||
(await Permission.find({})
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
uri: 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 }
|
||||
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.'
|
||||
}
|
||||
})) as unknown as PermissionDetailsResponse[]
|
||||
|
||||
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 ({
|
||||
uri,
|
||||
path,
|
||||
type,
|
||||
setting,
|
||||
principalType,
|
||||
principalId
|
||||
}: RegisterPermissionPayload): Promise<PermissionDetailsResponse> => {
|
||||
const permission = new Permission({
|
||||
uri,
|
||||
path,
|
||||
type,
|
||||
setting
|
||||
})
|
||||
|
||||
@@ -212,7 +243,8 @@ const createPermission = async ({
|
||||
}
|
||||
|
||||
const alreadyExists = await Permission.findOne({
|
||||
uri,
|
||||
path,
|
||||
type,
|
||||
user: userInDB._id
|
||||
})
|
||||
|
||||
@@ -220,7 +252,8 @@ const createPermission = async ({
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message: 'Permission already exists with provided URI and User.'
|
||||
message:
|
||||
'Permission already exists with provided Path, Type and User.'
|
||||
}
|
||||
|
||||
permission.user = userInDB._id
|
||||
@@ -243,14 +276,16 @@ const createPermission = async ({
|
||||
}
|
||||
|
||||
const alreadyExists = await Permission.findOne({
|
||||
uri,
|
||||
path,
|
||||
type,
|
||||
group: groupInDB._id
|
||||
})
|
||||
if (alreadyExists)
|
||||
throw {
|
||||
code: 409,
|
||||
status: 'Conflict',
|
||||
message: 'Permission already exists with provided URI and Group.'
|
||||
message:
|
||||
'Permission already exists with provided Path, Type and Group.'
|
||||
}
|
||||
|
||||
permission.group = groupInDB._id
|
||||
@@ -280,7 +315,8 @@ const createPermission = async ({
|
||||
|
||||
return {
|
||||
permissionId: savedPermission.permissionId,
|
||||
uri: savedPermission.uri,
|
||||
path: savedPermission.path,
|
||||
type: savedPermission.type,
|
||||
setting: savedPermission.setting,
|
||||
user,
|
||||
group
|
||||
@@ -301,7 +337,8 @@ const updatePermission = async (
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
uri: 1,
|
||||
path: 1,
|
||||
type: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
fetchLatestAutoExec,
|
||||
ModeType,
|
||||
verifyTokenInDB,
|
||||
isAuthorizingRoute
|
||||
isAuthorizingRoute,
|
||||
isPublicRoute,
|
||||
publicUser
|
||||
} from '../utils'
|
||||
import { desktopUser } from './desktop'
|
||||
import { authorize } from './authorize'
|
||||
@@ -41,7 +43,7 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
authenticateToken(
|
||||
await authenticateToken(
|
||||
req,
|
||||
res,
|
||||
nextFunction,
|
||||
@@ -50,8 +52,12 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
)
|
||||
}
|
||||
|
||||
export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
|
||||
authenticateToken(
|
||||
export const authenticateRefreshToken: RequestHandler = async (
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
await authenticateToken(
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
@@ -60,7 +66,7 @@ export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
|
||||
)
|
||||
}
|
||||
|
||||
const authenticateToken = (
|
||||
const authenticateToken = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
@@ -83,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,
|
||||
@@ -101,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { RequestHandler } from 'express'
|
||||
import User from '../model/User'
|
||||
import Permission from '../model/Permission'
|
||||
import { PermissionSetting } from '../controllers/permission'
|
||||
import { getUri } from '../utils'
|
||||
import {
|
||||
PermissionSettingForRoute,
|
||||
PermissionType
|
||||
} from '../controllers/permission'
|
||||
import { getPath, isPublicRoute } from '../utils'
|
||||
|
||||
export const authorize: RequestHandler = async (req, res, next) => {
|
||||
const { user } = req
|
||||
@@ -14,23 +17,35 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
||||
// 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 uri = getUri(req)
|
||||
const path = getPath(req)
|
||||
|
||||
// find permission w.r.t user
|
||||
const permission = await Permission.findOne({ uri, user: dbUser._id })
|
||||
const permission = await Permission.findOne({
|
||||
path,
|
||||
type: PermissionType.route,
|
||||
user: dbUser._id
|
||||
})
|
||||
|
||||
if (permission) {
|
||||
if (permission.setting === PermissionSetting.grant) return next()
|
||||
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({ uri, group })
|
||||
if (groupPermission?.setting === PermissionSetting.grant) return next()
|
||||
const groupPermission = await Permission.findOne({
|
||||
path,
|
||||
type: PermissionType.route,
|
||||
group
|
||||
})
|
||||
if (groupPermission?.setting === PermissionSettingForRoute.grant)
|
||||
return next()
|
||||
}
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ 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
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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 {
|
||||
uri: string
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
permissionId: number
|
||||
user: Schema.Types.ObjectId
|
||||
@@ -11,10 +18,16 @@ interface IPermissionDocument extends Document {
|
||||
|
||||
interface IPermission extends IPermissionDocument {}
|
||||
|
||||
interface IPermissionModel extends Model<IPermission> {}
|
||||
interface IPermissionModel extends Model<IPermission> {
|
||||
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
|
||||
}
|
||||
|
||||
const permissionSchema = new Schema<IPermissionDocument>({
|
||||
uri: {
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
@@ -28,6 +41,30 @@ const permissionSchema = new Schema<IPermissionDocument>({
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -11,7 +11,7 @@ const controller = new PermissionController()
|
||||
|
||||
permissionRouter.get('/', async (req, res) => {
|
||||
try {
|
||||
const response = await controller.getAllPermissions()
|
||||
const response = await controller.getAllPermissions(req)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
@@ -32,7 +32,8 @@ import appPromise from '../../../app'
|
||||
import {
|
||||
UserController,
|
||||
PermissionController,
|
||||
PermissionSetting,
|
||||
PermissionType,
|
||||
PermissionSettingForRoute,
|
||||
PrincipalType
|
||||
} from '../../../controllers/'
|
||||
import { getTreeExample } from '../../../controllers/internal'
|
||||
@@ -48,6 +49,12 @@ const user = {
|
||||
isActive: true
|
||||
}
|
||||
|
||||
const permission = {
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
}
|
||||
|
||||
describe('drive', () => {
|
||||
let app: Express
|
||||
let con: Mongoose
|
||||
@@ -66,28 +73,29 @@ describe('drive', () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/drive/deploy',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/drive/deploy/upload',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy/upload',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/drive/file',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/file',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/drive/folder',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/folder',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/rename',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
|
||||
@@ -543,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'
|
||||
|
||||
@@ -576,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', () => {
|
||||
@@ -627,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`
|
||||
|
||||
@@ -642,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'
|
||||
|
||||
@@ -657,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({})
|
||||
})
|
||||
|
||||
@@ -794,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'
|
||||
|
||||
@@ -815,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({})
|
||||
})
|
||||
|
||||
@@ -922,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({})
|
||||
})
|
||||
|
||||
@@ -966,6 +1058,139 @@ 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 =>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
DriveController,
|
||||
UserController,
|
||||
GroupController,
|
||||
ClientController,
|
||||
PermissionController,
|
||||
PrincipalType,
|
||||
PermissionSetting
|
||||
PermissionType,
|
||||
PermissionSettingForRoute
|
||||
} from '../../../controllers/'
|
||||
import {
|
||||
UserDetailsResponse,
|
||||
@@ -56,10 +56,10 @@ const user = {
|
||||
}
|
||||
|
||||
const permission = {
|
||||
uri: '/SASjsApi/code/execute',
|
||||
setting: PermissionSetting.grant,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: 123
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: PermissionType.route,
|
||||
setting: PermissionSettingForRoute.grant,
|
||||
principalType: PrincipalType.user
|
||||
}
|
||||
|
||||
const group = {
|
||||
@@ -69,7 +69,6 @@ const group = {
|
||||
|
||||
const userController = new UserController()
|
||||
const groupController = new GroupController()
|
||||
const clientController = new ClientController()
|
||||
const permissionController = new PermissionController()
|
||||
|
||||
describe('permission', () => {
|
||||
@@ -108,7 +107,8 @@ describe('permission', () => {
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.uri).toEqual(permission.uri)
|
||||
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()
|
||||
})
|
||||
@@ -127,7 +127,8 @@ describe('permission', () => {
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.uri).toEqual(permission.uri)
|
||||
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()
|
||||
})
|
||||
@@ -142,53 +143,74 @@ describe('permission', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not of an admin account even if user has permission', async () => {
|
||||
it('should respond with Unauthorized if access token is not of an admin account', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/permission',
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.send(permission)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if uri is missing', async () => {
|
||||
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,
|
||||
uri: undefined
|
||||
path: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"uri" is required`)
|
||||
expect(res.text).toEqual(`"path" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if uri is not valid', async () => {
|
||||
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,
|
||||
uri: '/some/random/api/endpoint'
|
||||
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')
|
||||
@@ -203,6 +225,20 @@ describe('permission', () => {
|
||||
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')
|
||||
@@ -217,20 +253,6 @@ describe('permission', () => {
|
||||
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 principal type is not valid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
@@ -245,17 +267,17 @@ describe('permission', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is not valid', async () => {
|
||||
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,
|
||||
setting: 'invalid'
|
||||
principalId: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
|
||||
expect(res.text).toEqual(`"principalId" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -313,7 +335,8 @@ describe('permission', () => {
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group'
|
||||
principalType: 'group',
|
||||
principalId: 123
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
@@ -334,7 +357,7 @@ describe('permission', () => {
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
'Permission already exists with provided URI and User.'
|
||||
'Permission already exists with provided Path, Type and User.'
|
||||
)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
@@ -357,7 +380,7 @@ describe('permission', () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ setting: 'Deny' })
|
||||
.send({ setting: PermissionSettingForRoute.deny })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.setting).toEqual('Deny')
|
||||
@@ -366,7 +389,7 @@ describe('permission', () => {
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.send(permission)
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
@@ -400,12 +423,11 @@ describe('permission', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if setting is not valid', async () => {
|
||||
it('should respond with Bad Request if setting is invalid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
setting: 'invalid'
|
||||
})
|
||||
.expect(400)
|
||||
@@ -414,12 +436,12 @@ describe('permission', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exists', async () => {
|
||||
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: PermissionSetting.deny
|
||||
setting: PermissionSettingForRoute.deny
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
@@ -458,12 +480,12 @@ describe('permission', () => {
|
||||
beforeAll(async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
uri: '/test-1',
|
||||
path: '/test-1',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
uri: '/test-2',
|
||||
path: '/test-2',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
@@ -478,34 +500,37 @@ describe('permission', () => {
|
||||
expect(res.body).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should give a list of all permissions when user is not admin', async () => {
|
||||
const dbUser = await userController.createUser({
|
||||
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(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(nonAdminUser.id)
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/permission',
|
||||
path: '/test-1',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
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(3)
|
||||
expect(res.body).toHaveLength(permissionCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe.only('verify', () => {
|
||||
describe('verify', () => {
|
||||
beforeAll(async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
uri: '/SASjsApi/drive/deploy',
|
||||
path: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,8 @@ import appPromise from '../../../app'
|
||||
import {
|
||||
UserController,
|
||||
PermissionController,
|
||||
PermissionSetting,
|
||||
PermissionType,
|
||||
PermissionSettingForRoute,
|
||||
PrincipalType
|
||||
} from '../../../controllers/'
|
||||
import {
|
||||
@@ -56,10 +57,11 @@ describe('stp', () => {
|
||||
const dbUser = await userController.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
await permissionController.createPermission({
|
||||
uri: '/SASjsApi/stp/execute',
|
||||
path: '/SASjsApi/stp/execute',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
setting: PermissionSetting.grant
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,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 }
|
||||
}
|
||||
|
||||
@@ -177,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]
|
||||
|
||||
@@ -26,6 +26,7 @@ export const style = `<style>
|
||||
}
|
||||
.app-container .app img{
|
||||
width: 100%;
|
||||
height: calc(100% - 30px);
|
||||
margin-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@ export interface TreeNode {
|
||||
name: string
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
isFolder: boolean
|
||||
children: Array<TreeNode>
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const StaticAuthorizedRoutes = [
|
||||
'/SASjsApi/drive/file',
|
||||
'/SASjsApi/drive/folder',
|
||||
'/SASjsApi/drive/fileTree',
|
||||
'/SASjsApi/permission'
|
||||
'/SASjsApi/drive/rename'
|
||||
]
|
||||
|
||||
export const getAuthorizedRoutes = () => {
|
||||
@@ -18,7 +18,7 @@ export const getAuthorizedRoutes = () => {
|
||||
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
|
||||
}
|
||||
|
||||
export const getUri = (req: Request) => {
|
||||
export const getPath = (req: Request) => {
|
||||
const { baseUrl, path: reqPath } = req
|
||||
|
||||
if (baseUrl === '/AppStream') {
|
||||
@@ -32,4 +32,4 @@ export const getUri = (req: Request) => {
|
||||
}
|
||||
|
||||
export const isAuthorizingRoute = (req: Request): boolean =>
|
||||
getAuthorizedRoutes().includes(getUri(req))
|
||||
getAuthorizedRoutes().includes(getPath(req))
|
||||
|
||||
@@ -16,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'
|
||||
|
||||
31
api/src/utils/isPublicRoute.ts
Normal file
31
api/src/utils/isPublicRoute.ts
Normal 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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Client from '../model/Client'
|
||||
import Group from '../model/Group'
|
||||
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
|
||||
import User from '../model/User'
|
||||
import Configuration, { ConfigurationType } from '../model/Configuration'
|
||||
|
||||
@@ -31,6 +31,15 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
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) {
|
||||
@@ -68,6 +77,13 @@ 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',
|
||||
clientSecret: 'clientSecret'
|
||||
|
||||
@@ -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}'`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import Joi from 'joi'
|
||||
import { PermissionSetting, PrincipalType } from '../controllers/permission'
|
||||
import {
|
||||
PermissionType,
|
||||
PermissionSettingForRoute,
|
||||
PrincipalType
|
||||
} from '../controllers/permission'
|
||||
import { getAuthorizedRoutes } from './getAuthorizedRoutes'
|
||||
|
||||
const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
|
||||
@@ -89,12 +93,15 @@ export const registerClientValidation = (data: any): Joi.ValidationResult =>
|
||||
|
||||
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
uri: Joi.string()
|
||||
path: Joi.string()
|
||||
.required()
|
||||
.valid(...getAuthorizedRoutes()),
|
||||
type: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PermissionType)),
|
||||
setting: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PermissionSetting)),
|
||||
.valid(...Object.values(PermissionSettingForRoute)),
|
||||
principalType: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PrincipalType)),
|
||||
@@ -105,7 +112,7 @@ export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
setting: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PermissionSetting))
|
||||
.valid(...Object.values(PermissionSettingForRoute))
|
||||
}).validate(data)
|
||||
|
||||
export const deployValidation = (data: any): Joi.ValidationResult =>
|
||||
@@ -138,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 =>
|
||||
|
||||
@@ -125,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 =
|
||||
|
||||
@@ -12,28 +12,16 @@
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Info",
|
||||
"description": "Get Server Information"
|
||||
},
|
||||
{
|
||||
"name": "Session",
|
||||
"description": "Get Session information"
|
||||
},
|
||||
{
|
||||
"name": "User",
|
||||
"description": "Operations with users"
|
||||
},
|
||||
{
|
||||
"name": "Permission",
|
||||
"description": "Operations about permissions"
|
||||
"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",
|
||||
@@ -43,13 +31,25 @@
|
||||
"name": "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": "Execution of Stored Programs"
|
||||
},
|
||||
{
|
||||
"name": "CODE",
|
||||
"description": "Execution of code (various runtimes are supported)"
|
||||
"name": "User",
|
||||
"description": "Operations with users"
|
||||
},
|
||||
{
|
||||
"name": "Web",
|
||||
|
||||
241
web/package-lock.json
generated
241
web/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
@@ -2312,19 +2312,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": {
|
||||
@@ -7128,16 +7132,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": {
|
||||
@@ -7829,11 +7828,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 +8386,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 +8948,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 +9335,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 +9634,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",
|
||||
@@ -10349,11 +10299,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 +10678,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",
|
||||
@@ -12642,9 +12582,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"
|
||||
}
|
||||
@@ -12989,11 +12929,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": {
|
||||
@@ -16587,16 +16527,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": {
|
||||
@@ -17084,11 +17019,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 +17460,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 +17882,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 +18173,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 +18414,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",
|
||||
@@ -19026,11 +18915,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 +19204,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",
|
||||
|
||||
@@ -9,7 +9,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",
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,22 +18,27 @@ const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
}
|
||||
}))
|
||||
|
||||
type DeleteModalProps = {
|
||||
type DeleteConfirmationModalProps = {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
deletePermission: () => void
|
||||
message: string
|
||||
_delete: () => void
|
||||
}
|
||||
|
||||
const DeleteModal = ({ open, setOpen, deletePermission }: DeleteModalProps) => {
|
||||
const DeleteConfirmationModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
message,
|
||||
_delete
|
||||
}: DeleteConfirmationModalProps) => {
|
||||
return (
|
||||
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
|
||||
<DialogContent dividers>
|
||||
<Typography gutterBottom>
|
||||
Are you sure you want to delete this permission?
|
||||
</Typography>
|
||||
<Typography gutterBottom>{message}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color="error" onClick={() => deletePermission()}>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button color="error" onClick={() => _delete()}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@@ -41,4 +46,4 @@ const DeleteModal = ({ open, setOpen, deletePermission }: DeleteModalProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteModal
|
||||
export default DeleteConfirmationModal
|
||||
83
web/src/components/filePathInputModal.tsx
Normal file
83
web/src/components/filePathInputModal.tsx
Normal 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
|
||||
@@ -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,38 +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={handleCloseUserMenu}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Settings />}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</MenuItem>
|
||||
<MenuItem sx={{ justifyContent: 'center' }}>
|
||||
<Button
|
||||
href={'https://server.sasjs.io'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
color="primary"
|
||||
endIcon={<OpenInNew />}
|
||||
>
|
||||
Documentation
|
||||
Docs
|
||||
</Button>
|
||||
</MenuItem>
|
||||
<MenuItem sx={{ justifyContent: 'center' }}>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/SASjsSettings"
|
||||
onClick={handleClose}
|
||||
href={`${baseUrl}/SASjsApi`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<SettingsIcon />}
|
||||
size="large"
|
||||
endIcon={<OpenInNew />}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout} sx={{ justifyContent: 'center' }}>
|
||||
<Button variant="contained" color="primary">
|
||||
Logout
|
||||
API
|
||||
</Button>
|
||||
</MenuItem>
|
||||
{appContext.loggedIn && (
|
||||
<MenuItem
|
||||
onClick={handleLogout}
|
||||
sx={{ justifyContent: 'center' }}
|
||||
>
|
||||
<Button variant="contained" color="primary">
|
||||
Logout
|
||||
</Button>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
</Toolbar>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { styled } from '@mui/material/styles'
|
||||
|
||||
import { BootstrapDialogTitle } from './dialogTitle'
|
||||
|
||||
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
export const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
'& .MuiDialogContent-root': {
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
@@ -14,7 +14,7 @@ const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
}
|
||||
}))
|
||||
|
||||
export interface ModalProps {
|
||||
type ModalProps = {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
title: string
|
||||
|
||||
109
web/src/components/nameInputModal.tsx
Normal file
109
web/src/components/nameInputModal.tsx
Normal 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
|
||||
247
web/src/components/tree.tsx
Normal file
247
web/src/components/tree.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -32,7 +32,13 @@ const BootstrapDialog = styled(Dialog)(({ theme }) => ({
|
||||
type AddPermissionModalProps = {
|
||||
open: boolean
|
||||
handleOpen: Dispatch<SetStateAction<boolean>>
|
||||
addPermission: (addPermissionPayload: RegisterPermissionPayload) => void
|
||||
addPermission: (
|
||||
permissions: RegisterPermissionPayload[],
|
||||
permissionType: string,
|
||||
principalType: string,
|
||||
principal: string,
|
||||
permissionSetting: string
|
||||
) => void
|
||||
}
|
||||
|
||||
const AddPermissionModal = ({
|
||||
@@ -40,10 +46,11 @@ const AddPermissionModal = ({
|
||||
handleOpen,
|
||||
addPermission
|
||||
}: AddPermissionModalProps) => {
|
||||
const [URIs, setURIs] = useState<string[]>([])
|
||||
const [loadingURIs, setLoadingURIs] = useState(false)
|
||||
const [uri, setUri] = useState<string>()
|
||||
const [principalType, setPrincipalType] = useState('user')
|
||||
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')
|
||||
@@ -52,29 +59,29 @@ const AddPermissionModal = ({
|
||||
const [groupPrincipals, setGroupPrincipals] = useState<GroupResponse[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingURIs(true)
|
||||
setLoadingPaths(true)
|
||||
axios
|
||||
.get('/SASjsApi/info/authorizedRoutes')
|
||||
.then((res: any) => {
|
||||
if (res.data) {
|
||||
setURIs(res.data.URIs)
|
||||
setPaths(res.data.paths)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingURIs(false)
|
||||
setLoadingPaths(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingPrincipals(true)
|
||||
axios
|
||||
.get(`/SASjsApi/${principalType}`)
|
||||
.get(`/SASjsApi/${principalType.toLowerCase()}`)
|
||||
.then((res: any) => {
|
||||
if (res.data) {
|
||||
if (principalType === 'user') {
|
||||
if (principalType.toLowerCase() === 'user') {
|
||||
const users: UserResponse[] = res.data
|
||||
const nonAdminUsers = users.filter((user) => !user.isAdmin)
|
||||
setUserPrincipals(nonAdminUsers)
|
||||
@@ -92,21 +99,40 @@ const AddPermissionModal = ({
|
||||
}, [principalType])
|
||||
|
||||
const handleAddPermission = () => {
|
||||
const addPermissionPayload: any = {
|
||||
uri,
|
||||
setting: permissionSetting,
|
||||
principalType
|
||||
}
|
||||
if (principalType === 'user' && userPrincipal) {
|
||||
addPermissionPayload.principalId = userPrincipal.id
|
||||
} else if (principalType === 'group' && groupPrincipal) {
|
||||
addPermissionPayload.principalId = groupPrincipal.groupId
|
||||
}
|
||||
addPermission(addPermissionPayload)
|
||||
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 =
|
||||
!uri || (principalType === 'user' ? !userPrincipal : !groupPrincipal)
|
||||
!selectedPaths.length ||
|
||||
(principalType.toLowerCase() === 'user' ? !userPrincipal : !groupPrincipal)
|
||||
|
||||
return (
|
||||
<BootstrapDialog onClose={() => handleOpen(false)} open={open}>
|
||||
@@ -120,22 +146,37 @@ const AddPermissionModal = ({
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
options={URIs}
|
||||
multiple
|
||||
disableClearable
|
||||
value={uri}
|
||||
onChange={(event: any, newValue: string) => setUri(newValue)}
|
||||
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) =>
|
||||
loadingURIs ? (
|
||||
loadingPaths ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<TextField {...params} label="Principal" />
|
||||
<TextField {...params} label="Permission Type" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
options={['user', 'group']}
|
||||
options={['Group', 'User']}
|
||||
disableClearable
|
||||
value={principalType}
|
||||
onChange={(event: any, newValue: string) =>
|
||||
@@ -147,7 +188,7 @@ const AddPermissionModal = ({
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
{principalType === 'user' ? (
|
||||
{principalType.toLowerCase() === 'user' ? (
|
||||
<Autocomplete
|
||||
options={userPrincipals}
|
||||
getOptionLabel={(option) => option.displayName}
|
||||
|
||||
120
web/src/containers/Settings/addPermissionResponseModal.tsx
Normal file
120
web/src/containers/Settings/addPermissionResponseModal.tsx
Normal 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
|
||||
@@ -31,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: {
|
||||
@@ -47,7 +56,7 @@ const Settings = () => {
|
||||
>
|
||||
<StyledTab label="Profile" value="profile" />
|
||||
{appContext.mode === ModeType.Server && (
|
||||
<StyledTab label="Uri Access" value="permission" />
|
||||
<StyledTab label="Permissions" value="permission" />
|
||||
)}
|
||||
</TabList>
|
||||
</Box>
|
||||
|
||||
@@ -27,8 +27,11 @@ 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 DeleteModal from './deletePermissionModal'
|
||||
import DeleteConfirmationModal from '../../components/deleteConfirmationModal'
|
||||
import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar'
|
||||
|
||||
import {
|
||||
@@ -36,12 +39,23 @@ import {
|
||||
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'
|
||||
@@ -59,13 +73,30 @@ const Permission = () => {
|
||||
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 [deleteModalOpen, setDeleteModalOpen] = useState(false)
|
||||
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
|
||||
useState(false)
|
||||
const [deleteConfirmationModalMessage, setDeleteConfirmationModalMessage] =
|
||||
useState('')
|
||||
const [selectedPermission, setSelectedPermission] =
|
||||
useState<PermissionResponse>()
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||
const [uriFilter, setUriFilter] = useState<string[]>([])
|
||||
const [pathFilter, setPathFilter] = useState<string[]>([])
|
||||
const [principalFilter, setPrincipalFilter] = useState<string[]>([])
|
||||
const [principalTypeFilter, setPrincipalTypeFilter] = useState<
|
||||
PrincipalType[]
|
||||
@@ -108,8 +139,10 @@ const Permission = () => {
|
||||
setFilterModalOpen(false)
|
||||
|
||||
const uriFilteredPermissions =
|
||||
uriFilter.length > 0
|
||||
? permissions.filter((permission) => uriFilter.includes(permission.uri))
|
||||
pathFilter.length > 0
|
||||
? permissions.filter((permission) =>
|
||||
pathFilter.includes(permission.path)
|
||||
)
|
||||
: permissions
|
||||
|
||||
const principalFilteredPermissions =
|
||||
@@ -169,36 +202,84 @@ const Permission = () => {
|
||||
|
||||
const resetFilter = () => {
|
||||
setFilterModalOpen(false)
|
||||
setUriFilter([])
|
||||
setPathFilter([])
|
||||
setPrincipalFilter([])
|
||||
setSettingFilter([])
|
||||
setFilteredPermissions([])
|
||||
setFilterApplied(false)
|
||||
}
|
||||
|
||||
const addPermission = (addPermissionPayload: RegisterPermissionPayload) => {
|
||||
const addPermission = async (
|
||||
permissionsToAdd: RegisterPermissionPayload[],
|
||||
permissionType: string,
|
||||
principalType: string,
|
||||
principal: string,
|
||||
permissionSetting: string
|
||||
) => {
|
||||
setAddPermissionModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.post('/SASjsApi/permission', addPermissionPayload)
|
||||
.then((res: any) => {
|
||||
fetchPermissions()
|
||||
setSnackbarMessage('Permission added!')
|
||||
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 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) => {
|
||||
@@ -236,11 +317,14 @@ const Permission = () => {
|
||||
|
||||
const handleDeletePermissionClick = (permission: PermissionResponse) => {
|
||||
setSelectedPermission(permission)
|
||||
setDeleteModalOpen(true)
|
||||
setDeleteConfirmationModalOpen(true)
|
||||
setDeleteConfirmationModalMessage(
|
||||
'Are you sure you want to delete this permission?'
|
||||
)
|
||||
}
|
||||
|
||||
const deletePermission = () => {
|
||||
setDeleteModalOpen(false)
|
||||
setDeleteConfirmationModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`)
|
||||
@@ -272,11 +356,11 @@ const Permission = () => {
|
||||
) : (
|
||||
<Box className="permissions-page">
|
||||
<Grid container direction="column" spacing={1}>
|
||||
<Grid item xs={12}>
|
||||
<BootstrapGridItem item xs={12}>
|
||||
<Paper elevation={3} sx={{ display: 'flex' }}>
|
||||
<Tooltip title="Filter Permissions">
|
||||
<IconButton>
|
||||
<FilterListIcon onClick={() => setFilterModalOpen(true)} />
|
||||
<IconButton onClick={() => setFilterModalOpen(true)}>
|
||||
<FilterListIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{appContext.isAdmin && (
|
||||
@@ -291,14 +375,14 @@ const Permission = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
</BootstrapGridItem>
|
||||
<BootstrapGridItem item xs={12}>
|
||||
<PermissionTable
|
||||
permissions={filterApplied ? filteredPermissions : permissions}
|
||||
handleUpdatePermissionClick={handleUpdatePermissionClick}
|
||||
handleDeletePermissionClick={handleDeletePermissionClick}
|
||||
/>
|
||||
</Grid>
|
||||
</BootstrapGridItem>
|
||||
</Grid>
|
||||
<BootstrapSnackbar
|
||||
open={openSnackbar}
|
||||
@@ -316,8 +400,8 @@ const Permission = () => {
|
||||
open={filterModalOpen}
|
||||
handleOpen={setFilterModalOpen}
|
||||
permissions={permissions}
|
||||
uriFilter={uriFilter}
|
||||
setUriFilter={setUriFilter}
|
||||
pathFilter={pathFilter}
|
||||
setPathFilter={setPathFilter}
|
||||
principalFilter={principalFilter}
|
||||
setPrincipalFilter={setPrincipalFilter}
|
||||
principalTypeFilter={principalTypeFilter}
|
||||
@@ -332,16 +416,22 @@ const Permission = () => {
|
||||
handleOpen={setAddPermissionModalOpen}
|
||||
addPermission={addPermission}
|
||||
/>
|
||||
<PermissionResponseModal
|
||||
open={openPermissionResponseModal}
|
||||
setOpen={setOpenPermissionResponseModal}
|
||||
payload={permissionResponsePayload}
|
||||
/>
|
||||
<UpdatePermissionModal
|
||||
open={updatePermissionModalOpen}
|
||||
handleOpen={setUpdatePermissionModalOpen}
|
||||
permission={selectedPermission}
|
||||
updatePermission={updatePermission}
|
||||
/>
|
||||
<DeleteModal
|
||||
open={deleteModalOpen}
|
||||
setOpen={setDeleteModalOpen}
|
||||
deletePermission={deletePermission}
|
||||
<DeleteConfirmationModal
|
||||
open={deleteConfirmationModalOpen}
|
||||
setOpen={setDeleteConfirmationModalOpen}
|
||||
message={deleteConfirmationModalMessage}
|
||||
_delete={deletePermission}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
@@ -367,9 +457,10 @@ const PermissionTable = ({
|
||||
<Table sx={{ minWidth: 650 }}>
|
||||
<TableHead sx={{ background: 'rgb(0,0,0, 0.3)' }}>
|
||||
<TableRow>
|
||||
<BootstrapTableCell>Uri</BootstrapTableCell>
|
||||
<BootstrapTableCell>Path</BootstrapTableCell>
|
||||
<BootstrapTableCell>Permission Type</BootstrapTableCell>
|
||||
<BootstrapTableCell>Principal</BootstrapTableCell>
|
||||
<BootstrapTableCell>Type</BootstrapTableCell>
|
||||
<BootstrapTableCell>Principal Type</BootstrapTableCell>
|
||||
<BootstrapTableCell>Setting</BootstrapTableCell>
|
||||
{appContext.isAdmin && (
|
||||
<BootstrapTableCell>Action</BootstrapTableCell>
|
||||
@@ -379,7 +470,8 @@ const PermissionTable = ({
|
||||
<TableBody>
|
||||
{permissions.map((permission) => (
|
||||
<TableRow key={permission.permissionId}>
|
||||
<BootstrapTableCell>{permission.uri}</BootstrapTableCell>
|
||||
<BootstrapTableCell>{permission.path}</BootstrapTableCell>
|
||||
<BootstrapTableCell>{permission.type}</BootstrapTableCell>
|
||||
<BootstrapTableCell>
|
||||
{displayPrincipal(permission)}
|
||||
</BootstrapTableCell>
|
||||
@@ -467,8 +559,8 @@ const DisplayGroup = ({ group }: DisplayGroupProps) => {
|
||||
<Typography sx={{ p: 1 }} variant="h6" component="div">
|
||||
Group Members
|
||||
</Typography>
|
||||
{group.users.map((user) => (
|
||||
<Typography sx={{ p: 1 }} component="li">
|
||||
{group.users.map((user, index) => (
|
||||
<Typography key={index} sx={{ p: 1 }} component="li">
|
||||
{user.username}
|
||||
</Typography>
|
||||
))}
|
||||
|
||||
@@ -27,8 +27,8 @@ type FilterModalProps = {
|
||||
open: boolean
|
||||
handleOpen: Dispatch<SetStateAction<boolean>>
|
||||
permissions: PermissionResponse[]
|
||||
uriFilter: string[]
|
||||
setUriFilter: Dispatch<SetStateAction<string[]>>
|
||||
pathFilter: string[]
|
||||
setPathFilter: Dispatch<SetStateAction<string[]>>
|
||||
principalFilter: string[]
|
||||
setPrincipalFilter: Dispatch<SetStateAction<string[]>>
|
||||
principalTypeFilter: PrincipalType[]
|
||||
@@ -43,8 +43,8 @@ const PermissionFilterModal = ({
|
||||
open,
|
||||
handleOpen,
|
||||
permissions,
|
||||
uriFilter,
|
||||
setUriFilter,
|
||||
pathFilter,
|
||||
setPathFilter,
|
||||
principalFilter,
|
||||
setPrincipalFilter,
|
||||
principalTypeFilter,
|
||||
@@ -54,8 +54,8 @@ const PermissionFilterModal = ({
|
||||
applyFilter,
|
||||
resetFilter
|
||||
}: FilterModalProps) => {
|
||||
const URIs = permissions
|
||||
.map((permission) => permission.uri)
|
||||
const paths = permissions
|
||||
.map((permission) => permission.path)
|
||||
.filter((uri, index, array) => array.indexOf(uri) === index)
|
||||
|
||||
// fetch all the principals from permissions array
|
||||
@@ -86,13 +86,13 @@ const PermissionFilterModal = ({
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={URIs}
|
||||
options={paths}
|
||||
filterSelectedOptions
|
||||
value={uriFilter}
|
||||
value={pathFilter}
|
||||
onChange={(event: any, newValue: string[]) => {
|
||||
setUriFilter(newValue)
|
||||
setPathFilter(newValue)
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="URIs" />}
|
||||
renderInput={(params) => <TextField {...params} label="Paths" />}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
|
||||
686
web/src/containers/Studio/editor.tsx
Normal file
686
web/src/containers/Studio/editor.tsx
Normal 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
|
||||
}
|
||||
@@ -1,253 +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 {
|
||||
Backdrop,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
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 [isRunning, setIsRunning] = useState(false)
|
||||
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) => {
|
||||
setIsRunning(true)
|
||||
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))
|
||||
.finally(() => setIsRunning(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)
|
||||
}
|
||||
|
||||
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">
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={isRunning}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
267
web/src/containers/Studio/sideBar.tsx
Normal file
267
web/src/containers/Studio/sideBar.tsx
Normal 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
|
||||
@@ -80,7 +80,18 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
})
|
||||
.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
|
||||
|
||||
@@ -13,7 +13,7 @@ code {
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-top: 50px;
|
||||
margin: 50px 10px 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -25,3 +25,15 @@ code {
|
||||
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
59
web/src/utils/helper.ts
Normal 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
|
||||
2
web/src/utils/hooks/index.ts
Normal file
2
web/src/utils/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './usePrompt'
|
||||
export * from './useStateWithCallback'
|
||||
36
web/src/utils/hooks/usePrompt.ts
Normal file
36
web/src/utils/hooks/usePrompt.ts
Normal 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)
|
||||
}
|
||||
27
web/src/utils/hooks/useStateWithCallback.ts
Normal file
27
web/src/utils/hooks/useStateWithCallback.ts
Normal 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
|
||||
@@ -18,15 +18,24 @@ export interface GroupDetailsResponse extends GroupResponse {
|
||||
|
||||
export interface PermissionResponse {
|
||||
permissionId: number
|
||||
uri: string
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
user?: UserResponse
|
||||
group?: GroupDetailsResponse
|
||||
}
|
||||
|
||||
export interface RegisterPermissionPayload {
|
||||
uri: string
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
principalType: string
|
||||
principalId: number
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
name: string
|
||||
relativePath: string
|
||||
isFolder: boolean
|
||||
children: Array<TreeNode>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user