diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index 5acf997..022e605 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -1,1742 +1,1963 @@ components: - examples: {} - headers: {} - parameters: {} - requestBodies: {} - responses: {} - schemas: - TokenResponse: - properties: - accessToken: - type: string - description: 'Access Token' - example: someRandomCryptoString - refreshToken: - type: string - description: 'Refresh Token' - example: someRandomCryptoString - required: - - accessToken - - refreshToken - type: object - additionalProperties: false - TokenPayload: - properties: - clientId: - type: string - description: 'Client ID' - example: clientID1 - code: - type: string - description: 'Authorization code' - example: someRandomCryptoString - required: - - clientId - - code - type: object - additionalProperties: false - InfoJWT: - properties: - clientId: - type: string - userId: - type: number - format: double - required: - - clientId - - userId - type: object - additionalProperties: false - ClientPayload: - properties: - clientId: - type: string - description: 'Client ID' - example: someFormattedClientID1234 - clientSecret: - type: string - description: 'Client Secret' - example: someRandomCryptoString - required: - - clientId - - clientSecret - type: object - additionalProperties: false - IRecordOfAny: - properties: {} - type: object - additionalProperties: {} - LogLine: - properties: - line: - type: string - required: - - line - type: object - additionalProperties: false - HTTPHeaders: - properties: {} - type: object - additionalProperties: - type: string - ExecuteReturnJsonResponse: - properties: - status: - type: string - _webout: - anyOf: - - - type: string - - - $ref: '#/components/schemas/IRecordOfAny' - log: - items: - $ref: '#/components/schemas/LogLine' - type: array - message: - type: string - httpHeaders: - $ref: '#/components/schemas/HTTPHeaders' - required: - - status - - _webout - - log - - httpHeaders - type: object - additionalProperties: false - RunTimeType: - enum: - - sas - - js + examples: {} + headers: {} + parameters: {} + requestBodies: {} + responses: {} + schemas: + TokenResponse: + properties: + accessToken: + type: string + description: 'Access Token' + example: someRandomCryptoString + refreshToken: + type: string + description: 'Refresh Token' + example: someRandomCryptoString + required: + - accessToken + - refreshToken + type: object + additionalProperties: false + TokenPayload: + properties: + clientId: + type: string + description: 'Client ID' + example: clientID1 + code: + type: string + description: 'Authorization code' + example: someRandomCryptoString + required: + - clientId + - code + type: object + additionalProperties: false + InfoJWT: + properties: + clientId: + type: string + userId: + type: number + format: double + required: + - clientId + - userId + type: object + additionalProperties: false + ClientPayload: + properties: + clientId: + type: string + description: 'Client ID' + example: someFormattedClientID1234 + clientSecret: + type: string + description: 'Client Secret' + example: someRandomCryptoString + required: + - clientId + - clientSecret + type: object + additionalProperties: false + IRecordOfAny: + properties: {} + type: object + additionalProperties: {} + LogLine: + properties: + line: + type: string + required: + - line + type: object + additionalProperties: false + HTTPHeaders: + properties: {} + type: object + additionalProperties: + type: string + ExecuteReturnJsonResponse: + properties: + status: + type: string + _webout: + anyOf: + - type: string + - $ref: '#/components/schemas/IRecordOfAny' + log: + items: + $ref: '#/components/schemas/LogLine' + type: array + message: + type: string + httpHeaders: + $ref: '#/components/schemas/HTTPHeaders' + required: + - status + - _webout + - log + - httpHeaders + type: object + additionalProperties: false + RunTimeType: + enum: + - sas + - js + type: string + ExecuteCodePayload: + properties: + code: + type: string + description: 'Code of program' + example: '* Code HERE;' + runTime: + $ref: '#/components/schemas/RunTimeType' + description: 'runtime for program' + example: js + required: + - code + - runTime + type: object + additionalProperties: false + MemberType.folder: + enum: + - folder + type: string + FolderMember: + properties: + name: + type: string + type: + $ref: '#/components/schemas/MemberType.folder' + members: + items: + anyOf: + - $ref: '#/components/schemas/FolderMember' + - $ref: '#/components/schemas/ServiceMember' + - $ref: '#/components/schemas/FileMember' + type: array + required: + - name + - type + - members + type: object + additionalProperties: false + MemberType.service: + enum: + - service + type: string + ServiceMember: + properties: + name: + type: string + type: + $ref: '#/components/schemas/MemberType.service' + code: + type: string + required: + - name + - type + - code + type: object + additionalProperties: false + MemberType.file: + enum: + - file + type: string + FileMember: + properties: + name: + type: string + type: + $ref: '#/components/schemas/MemberType.file' + code: + type: string + required: + - name + - type + - code + type: object + additionalProperties: false + FileTree: + properties: + members: + items: + anyOf: + - $ref: '#/components/schemas/FolderMember' + - $ref: '#/components/schemas/ServiceMember' + - $ref: '#/components/schemas/FileMember' + type: array + required: + - members + type: object + additionalProperties: false + DeployResponse: + properties: + status: + type: string + message: + type: string + streamServiceName: + type: string + example: + $ref: '#/components/schemas/FileTree' + required: + - status + - message + type: object + additionalProperties: false + DeployPayload: + properties: + appLoc: + type: string + streamWebFolder: + type: string + fileTree: + $ref: '#/components/schemas/FileTree' + required: + - appLoc + - fileTree + type: object + additionalProperties: false + FileFolderResponse: + properties: + status: + type: string + message: + type: string + required: + - status + type: object + additionalProperties: false + AddFolderPayload: + properties: + folderPath: + type: string + description: 'Location of folder' + example: /Public/someFolder + required: + - folderPath + type: object + additionalProperties: false + RenamePayload: + properties: + oldPath: + type: string + description: 'Old path of file/folder' + example: /Public/someFolder + newPath: + type: string + description: 'New path of file/folder' + example: /Public/newFolder + required: + - oldPath + - newPath + type: object + additionalProperties: false + TreeNode: + properties: + name: + type: string + relativePath: + type: string + absolutePath: + type: string + isFolder: + type: boolean + children: + items: + $ref: '#/components/schemas/TreeNode' + type: array + required: + - name + - relativePath + - absolutePath + - isFolder + - children + type: object + additionalProperties: false + GetFileTreeResponse: + properties: + status: + type: string + tree: + $ref: '#/components/schemas/TreeNode' + required: + - status + - tree + type: object + additionalProperties: false + UserResponse: + properties: + id: + type: number + format: double + username: + type: string + displayName: + type: string + isAdmin: + type: boolean + required: + - id + - username + - displayName + - isAdmin + type: object + additionalProperties: false + GroupResponse: + properties: + groupId: + type: number + format: double + name: + type: string + description: + type: string + required: + - groupId + - name + - description + type: object + additionalProperties: false + UserDetailsResponse: + properties: + id: + type: number + format: double + displayName: + type: string + username: + type: string + isActive: + type: boolean + isAdmin: + type: boolean + autoExec: + type: string + groups: + items: + $ref: '#/components/schemas/GroupResponse' + type: array + required: + - id + - displayName + - username + - isActive + - isAdmin + type: object + additionalProperties: false + UserPayload: + properties: + displayName: + type: string + description: 'Display name for user' + example: 'John Snow' + username: + type: string + description: 'Username for user' + example: johnSnow01 + password: + type: string + description: 'Password for user' + isAdmin: + type: boolean + description: 'Account should be admin or not, defaults to false' + example: 'false' + isActive: + type: boolean + description: 'Account should be active or not, defaults to true' + example: 'true' + autoExec: + type: string + description: 'User-specific auto-exec code' + example: '' + required: + - displayName + - username + - password + type: object + additionalProperties: false + GroupDetailsResponse: + properties: + groupId: + type: number + format: double + name: + type: string + description: + type: string + isActive: + type: boolean + users: + items: + $ref: '#/components/schemas/UserResponse' + type: array + required: + - groupId + - name + - description + - isActive + - users + type: object + additionalProperties: false + GroupPayload: + properties: + name: + type: string + description: 'Name of the group' + example: DCGroup + description: + type: string + description: 'Description of the group' + example: 'This group represents Data Controller Users' + isActive: + type: boolean + description: 'Group should be active or not, defaults to true' + example: 'true' + required: + - name + - description + type: object + additionalProperties: false + _LeanDocument__LeanDocument_T__: + properties: {} + type: object + Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__: + properties: + _id: + $ref: '#/components/schemas/_LeanDocument__LeanDocument_T__' + description: 'This documents _id.' + __v: + description: 'This documents __v.' + id: + description: 'The string version of this documents _id.' + type: object + description: 'From T, pick a set of properties whose keys are in the union K' + Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_: + $ref: '#/components/schemas/Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__' + description: 'Construct a type with the properties of T except for those in type K.' + LeanDocument_this_: + $ref: '#/components/schemas/Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_' + IGroup: + $ref: '#/components/schemas/LeanDocument_this_' + InfoResponse: + properties: + mode: + type: string + cors: + type: string + whiteList: + items: type: string - ExecuteCodePayload: - properties: - code: - type: string - description: 'Code of program' - example: '* Code HERE;' - runTime: - $ref: '#/components/schemas/RunTimeType' - description: 'runtime for program' - example: js - required: - - code - - runTime - type: object - additionalProperties: false - MemberType.folder: - enum: - - folder + type: array + protocol: + type: string + runTimes: + items: type: string - FolderMember: - properties: - name: - type: string - type: - $ref: '#/components/schemas/MemberType.folder' - members: - items: - anyOf: - - - $ref: '#/components/schemas/FolderMember' - - - $ref: '#/components/schemas/ServiceMember' - - - $ref: '#/components/schemas/FileMember' - type: array - required: - - name - - type - - members - type: object - additionalProperties: false - MemberType.service: - enum: - - service + type: array + required: + - mode + - cors + - whiteList + - protocol + - runTimes + type: object + additionalProperties: false + AuthorizedRoutesResponse: + properties: + URIs: + items: type: string - ServiceMember: - properties: - name: - type: string - type: - $ref: '#/components/schemas/MemberType.service' - code: - type: string - required: - - name - - type - - code - type: object - additionalProperties: false - MemberType.file: - enum: - - file - type: string - FileMember: - properties: - name: - type: string - type: - $ref: '#/components/schemas/MemberType.file' - code: - type: string - required: - - name - - type - - code - type: object - additionalProperties: false - FileTree: - properties: - members: - items: - anyOf: - - - $ref: '#/components/schemas/FolderMember' - - - $ref: '#/components/schemas/ServiceMember' - - - $ref: '#/components/schemas/FileMember' - type: array - required: - - members - type: object - additionalProperties: false - DeployResponse: - properties: - status: - type: string - message: - type: string - streamServiceName: - type: string - example: - $ref: '#/components/schemas/FileTree' - required: - - status - - message - type: object - additionalProperties: false - DeployPayload: - properties: - appLoc: - type: string - streamWebFolder: - type: string - fileTree: - $ref: '#/components/schemas/FileTree' - required: - - appLoc - - fileTree - type: object - additionalProperties: false - UpdateFileResponse: - properties: - status: - type: string - message: - type: string - required: - - status - type: object - additionalProperties: false - TreeNode: - properties: - name: - type: string - relativePath: - type: string - absolutePath: - type: string - children: - items: - $ref: '#/components/schemas/TreeNode' - type: array - required: - - name - - relativePath - - absolutePath - - children - type: object - additionalProperties: false - GetFileTreeResponse: - properties: - status: - type: string - tree: - $ref: '#/components/schemas/TreeNode' - required: - - status - - tree - type: object - additionalProperties: false - UserResponse: - properties: - id: - type: number - format: double - username: - type: string - displayName: - type: string - isAdmin: - type: boolean - required: - - id - - username - - displayName - - isAdmin - type: object - additionalProperties: false - GroupResponse: - properties: - groupId: - type: number - format: double - name: - type: string - description: - type: string - required: - - groupId - - name - - description - type: object - additionalProperties: false - UserDetailsResponse: - properties: - id: - type: number - format: double - displayName: - type: string - username: - type: string - isActive: - type: boolean - isAdmin: - type: boolean - autoExec: - type: string - groups: - items: - $ref: '#/components/schemas/GroupResponse' - type: array - required: - - id - - displayName - - username - - isActive - - isAdmin - type: object - additionalProperties: false - UserPayload: - properties: - displayName: - type: string - description: 'Display name for user' - example: 'John Snow' - username: - type: string - description: 'Username for user' - example: johnSnow01 - password: - type: string - description: 'Password for user' - isAdmin: - type: boolean - description: 'Account should be admin or not, defaults to false' - example: 'false' - isActive: - type: boolean - description: 'Account should be active or not, defaults to true' - example: 'true' - autoExec: - type: string - description: 'User-specific auto-exec code' - example: "" - required: - - displayName - - username - - password - type: object - additionalProperties: false - GroupDetailsResponse: - properties: - groupId: - type: number - format: double - name: - type: string - description: - type: string - isActive: - type: boolean - users: - items: - $ref: '#/components/schemas/UserResponse' - type: array - required: - - groupId - - name - - description - - isActive - - users - type: object - additionalProperties: false - GroupPayload: - properties: - name: - type: string - description: 'Name of the group' - example: DCGroup - description: - type: string - description: 'Description of the group' - example: 'This group represents Data Controller Users' - isActive: - type: boolean - description: 'Group should be active or not, defaults to true' - example: 'true' - required: - - name - - description - type: object - additionalProperties: false - _LeanDocument__LeanDocument_T__: - properties: {} - type: object - Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__: - properties: - _id: - $ref: '#/components/schemas/_LeanDocument__LeanDocument_T__' - description: 'This documents _id.' - __v: - description: 'This documents __v.' - id: - description: 'The string version of this documents _id.' - type: object - description: 'From T, pick a set of properties whose keys are in the union K' - Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_: - $ref: '#/components/schemas/Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__' - description: 'Construct a type with the properties of T except for those in type K.' - LeanDocument_this_: - $ref: '#/components/schemas/Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_' - IGroup: - $ref: '#/components/schemas/LeanDocument_this_' - InfoResponse: - properties: - mode: - type: string - cors: - type: string - whiteList: - items: - type: string - type: array - protocol: - type: string - runTimes: - items: - type: string - type: array - required: - - mode - - cors - - whiteList - - protocol - - runTimes - type: object - additionalProperties: false - AuthorizedRoutesResponse: - properties: - URIs: - items: - type: string - type: array - required: - - URIs - type: object - additionalProperties: false - ExecuteReturnJsonPayload: - properties: - _program: - type: string - description: 'Location of SAS program' - example: /Public/somefolder/some.file - type: object - additionalProperties: false - LoginPayload: - properties: - username: - type: string - description: 'Username for user' - example: secretuser - password: - type: string - description: 'Password for user' - example: secretpassword - required: - - username - - password - type: object - additionalProperties: false - AuthorizeResponse: - properties: - code: - type: string - description: 'Authorization code' - example: someRandomCryptoString - required: - - code - type: object - additionalProperties: false - AuthorizePayload: - properties: - clientId: - type: string - description: 'Client ID' - example: clientID1 - required: - - clientId - type: object - additionalProperties: false - PermissionDetailsResponse: - properties: - permissionId: - type: number - format: double - uri: - type: string - setting: - type: string - user: - $ref: '#/components/schemas/UserResponse' - group: - $ref: '#/components/schemas/GroupDetailsResponse' - required: - - permissionId - - uri - - setting - type: object - additionalProperties: false - PermissionSetting: - enum: - - Grant - - Deny - type: string - PrincipalType: - enum: - - user - - group - type: string - RegisterPermissionPayload: - properties: - uri: - type: string - description: 'Name of affected resource' - example: /SASjsApi/code/execute - setting: - $ref: '#/components/schemas/PermissionSetting' - description: 'The indication of whether (and to what extent) access is provided' - example: Grant - principalType: - $ref: '#/components/schemas/PrincipalType' - description: 'Indicates the type of principal' - example: user - principalId: - type: number - format: double - description: 'The id of user or group to which a rule is assigned.' - example: 123 - required: - - uri - - setting - - principalType - - principalId - type: object - additionalProperties: false - UpdatePermissionPayload: - properties: - setting: - $ref: '#/components/schemas/PermissionSetting' - description: 'The indication of whether (and to what extent) access is provided' - example: Grant - required: - - setting - type: object - additionalProperties: false - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT + type: array + required: + - URIs + type: object + additionalProperties: false + ExecuteReturnJsonPayload: + properties: + _program: + type: string + description: 'Location of SAS program' + example: /Public/somefolder/some.file + type: object + additionalProperties: false + LoginPayload: + properties: + username: + type: string + description: 'Username for user' + example: secretuser + password: + type: string + description: 'Password for user' + example: secretpassword + required: + - username + - password + type: object + additionalProperties: false + AuthorizeResponse: + properties: + code: + type: string + description: 'Authorization code' + example: someRandomCryptoString + required: + - code + type: object + additionalProperties: false + AuthorizePayload: + properties: + clientId: + type: string + description: 'Client ID' + example: clientID1 + required: + - clientId + type: object + additionalProperties: false + PermissionDetailsResponse: + properties: + permissionId: + type: number + format: double + uri: + type: string + setting: + type: string + user: + $ref: '#/components/schemas/UserResponse' + group: + $ref: '#/components/schemas/GroupDetailsResponse' + required: + - permissionId + - uri + - setting + type: object + additionalProperties: false + PermissionSetting: + enum: + - Grant + - Deny + type: string + PrincipalType: + enum: + - user + - group + type: string + RegisterPermissionPayload: + properties: + uri: + type: string + description: 'Name of affected resource' + example: /SASjsApi/code/execute + setting: + $ref: '#/components/schemas/PermissionSetting' + description: 'The indication of whether (and to what extent) access is provided' + example: Grant + principalType: + $ref: '#/components/schemas/PrincipalType' + description: 'Indicates the type of principal' + example: user + principalId: + type: number + format: double + description: 'The id of user or group to which a rule is assigned.' + example: 123 + required: + - uri + - setting + - principalType + - principalId + type: object + additionalProperties: false + UpdatePermissionPayload: + properties: + setting: + $ref: '#/components/schemas/PermissionSetting' + description: 'The indication of whether (and to what extent) access is provided' + example: Grant + required: + - setting + type: object + additionalProperties: false + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT info: - title: api - version: 0.0.2 - description: 'Api of SASjs server' - contact: - name: '4GL Ltd' + title: api + version: 0.0.2 + description: 'Api of SASjs server' + contact: + name: '4GL Ltd' openapi: 3.0.0 paths: - /SASjsApi/auth/token: - post: - operationId: Token - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/TokenResponse' - examples: - 'Example 1': - value: {accessToken: someRandomCryptoString, refreshToken: someRandomCryptoString} - summary: 'Accepts client/auth code and returns access/refresh tokens' - tags: - - Auth - security: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TokenPayload' - /SASjsApi/auth/refresh: - post: - operationId: Refresh - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/TokenResponse' - examples: - 'Example 1': - value: {accessToken: someRandomCryptoString, refreshToken: someRandomCryptoString} - summary: 'Returns new access/refresh tokens' - tags: - - Auth - security: - - - bearerAuth: [] - parameters: [] - /SASjsApi/auth/logout: - post: - operationId: Logout - responses: - '204': - description: 'No content' - summary: 'Logout terminate access/refresh tokens and returns nothing' - tags: - - Auth - security: - - - bearerAuth: [] - parameters: [] - /SASjsApi/client: - post: - operationId: CreateClient - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/ClientPayload' - examples: - 'Example 1': - value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString} - summary: 'Create client with the following attributes: ClientId, ClientSecret. Admin only task.' - tags: - - Client - security: - - - bearerAuth: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ClientPayload' - /SASjsApi/code/execute: - post: - operationId: ExecuteCode - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/ExecuteReturnJsonResponse' - description: 'Execute SAS code.' - summary: 'Run SAS Code and returns log' - tags: - - CODE - security: - - - bearerAuth: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ExecuteCodePayload' - /SASjsApi/drive/deploy: - post: - operationId: Deploy - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/DeployResponse' - examples: - 'Example 1': - value: {status: success, message: 'Files deployed successfully to @sasjs/server.'} - '400': - description: 'Invalid Format' - content: - application/json: - schema: - $ref: '#/components/schemas/DeployResponse' - examples: - 'Example 1': - value: {status: failure, message: 'Provided not supported data format.'} - '500': - description: 'Execution Error' - content: - application/json: - schema: - $ref: '#/components/schemas/DeployResponse' - examples: - 'Example 1': - value: {status: failure, message: 'Deployment failed!'} - summary: 'Creates/updates files within SASjs Drive using provided payload.' - tags: - - Drive - security: - - - bearerAuth: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DeployPayload' - /SASjsApi/drive/deploy/upload: - post: - operationId: DeployUpload - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/DeployResponse' - examples: - 'Example 1': - value: {status: success, message: 'Files deployed successfully to @sasjs/server.'} - '400': - description: 'Invalid Format' - content: - application/json: - schema: - $ref: '#/components/schemas/DeployResponse' - examples: - 'Example 1': - value: {status: failure, message: 'Provided not supported data format.'} - '500': - description: 'Execution Error' - content: - application/json: - schema: - $ref: '#/components/schemas/DeployResponse' - examples: - 'Example 1': - value: {status: failure, message: 'Deployment failed!'} - description: "Accepts JSON file and zipped compressed JSON file as well.\nCompressed file should only contain one JSON file and should have same name\nas of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip\nAny other file or JSON file in zipped will be ignored!" - summary: 'Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file.' - tags: - - Drive - security: - - - bearerAuth: [] - parameters: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - file: - type: string - format: binary - required: - - file - /SASjsApi/drive/file: - get: - operationId: GetFile - responses: - '204': - description: 'No content' - summary: 'Get file from SASjs Drive' - tags: - - Drive - security: - - - bearerAuth: [] - parameters: - - - in: query - name: _filePath - required: true - schema: - type: string - example: /Public/somefolder/some.file - delete: - operationId: DeleteFile - responses: - '200': - description: Ok - content: - application/json: - schema: - properties: - status: {type: string} - required: - - status - type: object - summary: 'Delete file from SASjs Drive' - tags: - - Drive - security: - - - bearerAuth: [] - parameters: - - - in: query - name: _filePath - required: true - schema: - type: string - example: /Public/somefolder/some.file - post: - operationId: SaveFile - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateFileResponse' - examples: - 'Example 1': - value: {status: success} - '403': - description: 'File already exists' - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateFileResponse' - examples: - 'Example 1': - value: {status: failure, message: 'File request failed.'} - description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." - summary: 'Create a file in SASjs Drive' - tags: - - Drive - security: - - - bearerAuth: [] - parameters: - - - description: 'Location of SAS program' - in: query - name: _filePath - required: false - schema: - type: string - example: /Public/somefolder/some.file.sas - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - file: - type: string - format: binary - filePath: - type: string - required: - - file - patch: - operationId: UpdateFile - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateFileResponse' - examples: - 'Example 1': - value: {status: success} - '403': - description: "" - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateFileResponse' - examples: - 'Example 1': - value: {status: failure, message: 'File request failed.'} - description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." - summary: 'Modify a file in SASjs Drive' - tags: - - Drive - security: - - - bearerAuth: [] - parameters: - - - description: 'Location of SAS program' - in: query - name: _filePath - required: false - schema: - type: string - example: /Public/somefolder/some.file.sas - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - file: - type: string - format: binary - filePath: - type: string - required: - - file - /SASjsApi/drive/folder: - get: - operationId: GetFolder - responses: - '200': - description: Ok - content: - application/json: - schema: - properties: - folders: {items: {type: string}, type: array} - files: {items: {type: string}, type: array} - required: - - folders - - files - type: object - summary: 'Get folder contents from SASjs Drive' - tags: - - Drive - security: - - - bearerAuth: [] - parameters: - - - in: query - name: _folderPath - required: false - schema: - type: string - example: /Public/somefolder - /SASjsApi/drive/filetree: - get: - operationId: GetFileTree - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/GetFileTreeResponse' - summary: 'Fetch file tree within SASjs Drive.' - tags: - - Drive - security: - - - bearerAuth: [] - parameters: [] - /SASjsApi/user: - get: - operationId: GetAllUsers - responses: - '200': - description: Ok - content: - application/json: - schema: - items: - $ref: '#/components/schemas/UserResponse' - type: array - examples: - 'Example 1': - value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}] - summary: 'Get list of all users (username, displayname). All users can request this.' - tags: - - User - security: - - - bearerAuth: [] - parameters: [] - post: - operationId: CreateUser - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/UserDetailsResponse' - examples: - 'Example 1': - value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true} - summary: 'Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.' - tags: - - User - security: - - - bearerAuth: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserPayload' - '/SASjsApi/user/by/username/{username}': - get: - operationId: GetUserByUsername - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/UserDetailsResponse' - description: 'Only Admin or user itself will get user autoExec code.' - summary: 'Get user properties - such as group memberships, userName, displayName.' - tags: - - User - security: - - - bearerAuth: [] - parameters: - - - description: 'The User''s username' - in: path - name: username - required: true - schema: - type: string - example: johnSnow01 - patch: - operationId: UpdateUserByUsername - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/UserDetailsResponse' - examples: - 'Example 1': - value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true} - summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.' - tags: - - User - security: - - - bearerAuth: [] - parameters: - - - description: 'The User''s username' - in: path - name: username - required: true - schema: - type: string - example: johnSnow01 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserPayload' - delete: - operationId: DeleteUserByUsername - responses: - '204': - description: 'No content' - summary: 'Delete a user. Can be performed either by admins, or the user in question.' - tags: - - User - security: - - - bearerAuth: [] - parameters: - - - description: 'The User''s username' - in: path - name: username - required: true - schema: - type: string - example: johnSnow01 - requestBody: - required: true - content: - application/json: - schema: - properties: - password: - type: string - type: object - '/SASjsApi/user/{userId}': - get: - operationId: GetUser - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/UserDetailsResponse' - description: 'Only Admin or user itself will get user autoExec code.' - summary: 'Get user properties - such as group memberships, userName, displayName.' - tags: - - User - security: - - - bearerAuth: [] - parameters: - - - description: 'The user''s identifier' - in: path - name: userId - required: true - schema: - format: double - type: number - example: 1234 - patch: - operationId: UpdateUser - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/UserDetailsResponse' - examples: - 'Example 1': - value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true} - summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.' - tags: - - User - security: - - - bearerAuth: [] - parameters: - - - description: 'The user''s identifier' - in: path - name: userId - required: true - schema: - format: double - type: number - example: '1234' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserPayload' - delete: - operationId: DeleteUser - responses: - '204': - description: 'No content' - summary: 'Delete a user. Can be performed either by admins, or the user in question.' - tags: - - User - security: - - - bearerAuth: [] - parameters: - - - description: 'The user''s identifier' - in: path - name: userId - required: true - schema: - format: double - type: number - example: 1234 - requestBody: - required: true - content: - application/json: - schema: - properties: - password: - type: string - type: object - /SASjsApi/group: - get: - operationId: GetAllGroups - responses: - '200': - description: Ok - content: - application/json: - schema: - items: - $ref: '#/components/schemas/GroupResponse' - type: array - examples: - 'Example 1': - value: [{groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users'}] - summary: 'Get list of all groups (groupName and groupDescription). All users can request this.' - tags: - - Group - security: - - - bearerAuth: [] - parameters: [] - post: - operationId: CreateGroup - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/GroupDetailsResponse' - examples: - 'Example 1': - value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} - summary: 'Create a new group. Admin only.' - tags: - - Group - security: - - - bearerAuth: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/GroupPayload' - '/SASjsApi/group/by/groupname/{name}': - get: - operationId: GetGroupByGroupName - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/GroupDetailsResponse' - summary: 'Get list of members of a group (userName). All users can request this.' - tags: - - Group - security: - - - bearerAuth: [] - parameters: - - - description: 'The group''s name' - in: path - name: name - required: true - schema: - type: string - '/SASjsApi/group/{groupId}': - get: - operationId: GetGroup - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/GroupDetailsResponse' - summary: 'Get list of members of a group (userName). All users can request this.' - tags: - - Group - security: - - - bearerAuth: [] - parameters: - - - description: 'The group''s identifier' - in: path - name: groupId - required: true - schema: - format: double - type: number - example: 1234 - delete: - operationId: DeleteGroup - responses: - '200': - description: Ok - content: - application/json: - schema: - allOf: - - {$ref: '#/components/schemas/IGroup'} - - {properties: {_id: {}}, required: [_id], type: object} - summary: 'Delete a group. Admin task only.' - tags: - - Group - security: - - - bearerAuth: [] - parameters: - - - description: 'The group''s identifier' - in: path - name: groupId - required: true - schema: - format: double - type: number - example: 1234 - '/SASjsApi/group/{groupId}/{userId}': - post: - operationId: AddUserToGroup - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/GroupDetailsResponse' - examples: - 'Example 1': - value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} - summary: 'Add a user to a group. Admin task only.' - tags: - - Group - security: - - - bearerAuth: [] - parameters: - - - description: 'The group''s identifier' - in: path - name: groupId - required: true - schema: - format: double - type: number - example: '1234' - - - description: 'The user''s identifier' - in: path - name: userId - required: true - schema: - format: double - type: number - example: '6789' - delete: - operationId: RemoveUserFromGroup - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/GroupDetailsResponse' - examples: - 'Example 1': - value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} - summary: 'Remove a user to a group. Admin task only.' - tags: - - Group - security: - - - bearerAuth: [] - parameters: - - - description: 'The group''s identifier' - in: path - name: groupId - required: true - schema: - format: double - type: number - example: '1234' - - - description: 'The user''s identifier' - in: path - name: userId - required: true - schema: - format: double - type: number - example: '6789' - /SASjsApi/info: - get: - operationId: Info - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/InfoResponse' - examples: - 'Example 1': - value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http, runTimes: [sas, js]} - summary: 'Get server info (mode, cors, whiteList, protocol).' - tags: - - Info - security: [] - parameters: [] - /SASjsApi/info/authorizedRoutes: - get: - operationId: AuthorizedRoutes - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/AuthorizedRoutesResponse' - examples: - 'Example 1': - value: {URIs: [/AppStream, /SASjsApi/stp/execute]} - summary: 'Get authorized routes.' - tags: - - Info - security: [] - parameters: [] - /SASjsApi/session: - get: - operationId: Session - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/UserResponse' - examples: - 'Example 1': - value: {id: 123, username: johnusername, displayName: John, isAdmin: false} - summary: 'Get session info (username).' - tags: - - Session - security: - - - bearerAuth: [] - parameters: [] - /SASjsApi/stp/execute: - get: - operationId: ExecuteReturnRaw - responses: - '200': - description: Ok - content: - application/json: - schema: - anyOf: - - {type: string} - - {type: string, format: byte} - description: "Trigger a SAS or JS program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms" - summary: 'Execute a Stored Program, returns raw _webout content.' - tags: - - STP - security: - - - bearerAuth: [] - parameters: - - - description: 'Location of SAS or JS code' - in: query - name: _program - required: true - schema: - type: string - example: /Projects/myApp/some/program - post: - operationId: ExecuteReturnJson - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/ExecuteReturnJsonResponse' - examples: - 'Example 1': - value: {status: success, _webout: 'webout content', log: [], httpHeaders: {Content-type: application/zip, Cache-Control: 'public, max-age=1000'}} - description: "Trigger a SAS or JS program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms\n\nThe response will be a JSON object with the following root attributes:\nlog, webout, headers.\n\nThe webout attribute will be nested JSON ONLY if the response-header\ncontains a content-type of application/json AND it is valid JSON.\nOtherwise it will be a stringified version of the webout content." - summary: 'Execute a Stored Program, return a JSON object' - tags: - - STP - security: - - - bearerAuth: [] - parameters: - - - description: 'Location of SAS or JS code' - in: query - name: _program - required: false - schema: - type: string - example: /Projects/myApp/some/program - requestBody: - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/ExecuteReturnJsonPayload' - /: - get: - operationId: Home - responses: - '200': - description: Ok - content: - application/json: - schema: - type: string - summary: 'Render index.html' - tags: - - Web - security: [] - parameters: [] - /SASLogon/login: - post: - operationId: Login - responses: - '200': - description: Ok - content: - application/json: - schema: - properties: - user: {properties: {isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [isAdmin, displayName, username, id], type: object} - loggedIn: {type: boolean} - required: - - user - - loggedIn - type: object - summary: 'Accept a valid username/password' - tags: - - Web - security: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LoginPayload' - /SASLogon/authorize: - post: - operationId: Authorize - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/AuthorizeResponse' - examples: - 'Example 1': - value: {code: someRandomCryptoString} - summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE' - tags: - - Web - security: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AuthorizePayload' - /SASLogon/logout: - get: - operationId: Logout - responses: - '200': - description: Ok - content: - application/json: - schema: {} - summary: 'Destroy the session stored in cookies' - tags: - - Web - security: [] - parameters: [] - /SASjsApi/permission: - get: - operationId: GetAllPermissions - responses: - '200': - description: Ok - content: - application/json: - schema: - items: - $ref: '#/components/schemas/PermissionDetailsResponse' - type: array - examples: - 'Example 1': - value: [{permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {permissionId: 124, uri: /SASjsApi/code/execute, setting: Grant, group: {groupId: 1, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}] - summary: 'Get list of all permissions (uri, setting and userDetail).' - tags: - - Permission - security: - - - bearerAuth: [] - parameters: [] - post: - operationId: CreatePermission - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/PermissionDetailsResponse' - examples: - 'Example 1': - value: {permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}} - summary: 'Create a new permission. Admin only.' - tags: - - Permission - security: - - - bearerAuth: [] - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterPermissionPayload' - '/SASjsApi/permission/{permissionId}': - patch: - operationId: UpdatePermission - responses: - '200': - description: Ok - content: - application/json: - schema: - $ref: '#/components/schemas/PermissionDetailsResponse' - examples: - 'Example 1': - value: {permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}} - summary: 'Update permission setting. Admin only' - tags: - - Permission - security: - - - bearerAuth: [] - parameters: - - - description: 'The permission''s identifier' - in: path - name: permissionId - required: true - schema: - format: double - type: number - example: 1234 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdatePermissionPayload' - delete: - operationId: DeletePermission - responses: - '204': - description: 'No content' - summary: 'Delete a permission. Admin only.' - tags: - - Permission - security: - - - bearerAuth: [] - parameters: - - - description: 'The user''s identifier' - in: path - name: permissionId - required: true - schema: - format: double - type: number - example: 1234 + /SASjsApi/auth/token: + post: + operationId: Token + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + examples: + 'Example 1': + value: + { + accessToken: someRandomCryptoString, + refreshToken: someRandomCryptoString + } + summary: 'Accepts client/auth code and returns access/refresh tokens' + tags: + - Auth + security: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenPayload' + /SASjsApi/auth/refresh: + post: + operationId: Refresh + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + examples: + 'Example 1': + value: + { + accessToken: someRandomCryptoString, + refreshToken: someRandomCryptoString + } + summary: 'Returns new access/refresh tokens' + tags: + - Auth + security: + - bearerAuth: [] + parameters: [] + /SASjsApi/auth/logout: + post: + operationId: Logout + responses: + '204': + description: 'No content' + summary: 'Logout terminate access/refresh tokens and returns nothing' + tags: + - Auth + security: + - bearerAuth: [] + parameters: [] + /SASjsApi/client: + post: + operationId: CreateClient + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/ClientPayload' + examples: + 'Example 1': + value: + { + clientId: someFormattedClientID1234, + clientSecret: someRandomCryptoString + } + summary: 'Create client with the following attributes: ClientId, ClientSecret. Admin only task.' + tags: + - Client + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ClientPayload' + /SASjsApi/code/execute: + post: + operationId: ExecuteCode + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/ExecuteReturnJsonResponse' + description: 'Execute SAS code.' + summary: 'Run SAS Code and returns log' + tags: + - CODE + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExecuteCodePayload' + /SASjsApi/drive/deploy: + post: + operationId: Deploy + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/DeployResponse' + examples: + 'Example 1': + value: + { + status: success, + message: 'Files deployed successfully to @sasjs/server.' + } + '400': + description: 'Invalid Format' + content: + application/json: + schema: + $ref: '#/components/schemas/DeployResponse' + examples: + 'Example 1': + value: + { + status: failure, + message: 'Provided not supported data format.' + } + '500': + description: 'Execution Error' + content: + application/json: + schema: + $ref: '#/components/schemas/DeployResponse' + examples: + 'Example 1': + value: { status: failure, message: 'Deployment failed!' } + summary: 'Creates/updates files within SASjs Drive using provided payload.' + tags: + - Drive + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeployPayload' + /SASjsApi/drive/deploy/upload: + post: + operationId: DeployUpload + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/DeployResponse' + examples: + 'Example 1': + value: + { + status: success, + message: 'Files deployed successfully to @sasjs/server.' + } + '400': + description: 'Invalid Format' + content: + application/json: + schema: + $ref: '#/components/schemas/DeployResponse' + examples: + 'Example 1': + value: + { + status: failure, + message: 'Provided not supported data format.' + } + '500': + description: 'Execution Error' + content: + application/json: + schema: + $ref: '#/components/schemas/DeployResponse' + examples: + 'Example 1': + value: { status: failure, message: 'Deployment failed!' } + description: "Accepts JSON file and zipped compressed JSON file as well.\nCompressed file should only contain one JSON file and should have same name\nas of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip\nAny other file or JSON file in zipped will be ignored!" + summary: 'Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file.' + tags: + - Drive + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + required: + - file + /SASjsApi/drive/file: + get: + operationId: GetFile + responses: + '204': + description: 'No content' + summary: 'Get file from SASjs Drive' + tags: + - Drive + security: + - bearerAuth: [] + parameters: + - in: query + name: _filePath + required: true + schema: + type: string + example: /Public/somefolder/some.file + delete: + operationId: DeleteFile + responses: + '200': + description: Ok + content: + application/json: + schema: + properties: + status: { type: string } + required: + - status + type: object + summary: 'Delete file from SASjs Drive' + tags: + - Drive + security: + - bearerAuth: [] + parameters: + - in: query + name: _filePath + required: true + schema: + type: string + example: /Public/somefolder/some.file + post: + operationId: SaveFile + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/FileFolderResponse' + examples: + 'Example 1': + value: { status: success } + '403': + description: 'File already exists' + content: + application/json: + schema: + $ref: '#/components/schemas/FileFolderResponse' + examples: + 'Example 1': + value: { status: failure, message: 'File request failed.' } + description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." + summary: 'Create a file in SASjs Drive' + tags: + - Drive + security: + - bearerAuth: [] + parameters: + - description: 'Location of file' + in: query + name: _filePath + required: false + schema: + type: string + example: /Public/somefolder/some.file.sas + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + filePath: + type: string + required: + - file + patch: + operationId: UpdateFile + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/FileFolderResponse' + examples: + 'Example 1': + value: { status: success } + '403': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/FileFolderResponse' + examples: + 'Example 1': + value: { status: failure, message: 'File request failed.' } + description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." + summary: 'Modify a file in SASjs Drive' + tags: + - Drive + security: + - bearerAuth: [] + parameters: + - description: 'Location of SAS program' + in: query + name: _filePath + required: false + schema: + type: string + example: /Public/somefolder/some.file.sas + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + filePath: + type: string + required: + - file + /SASjsApi/drive/folder: + get: + operationId: GetFolder + responses: + '200': + description: Ok + content: + application/json: + schema: + properties: + folders: { items: { type: string }, type: array } + files: { items: { type: string }, type: array } + required: + - folders + - files + type: object + summary: 'Get folder contents from SASjs Drive' + tags: + - Drive + security: + - bearerAuth: [] + parameters: + - in: query + name: _folderPath + required: false + schema: + type: string + example: /Public/somefolder + delete: + operationId: DeleteFolder + responses: + '200': + description: Ok + content: + application/json: + schema: + properties: + status: { type: string } + required: + - status + type: object + summary: 'Delete folder from SASjs Drive' + tags: + - Drive + security: + - bearerAuth: [] + parameters: + - in: query + name: _folderPath + required: true + schema: + type: string + example: /Public/somefolder/ + post: + operationId: AddFolder + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/FileFolderResponse' + examples: + 'Example 1': + value: { status: success } + '409': + description: 'Folder already exists' + content: + application/json: + schema: + $ref: '#/components/schemas/FileFolderResponse' + examples: + 'Example 1': + value: + { status: failure, message: 'Add folder request failed.' } + summary: 'Create an empty folder in SASjs Drive' + tags: + - Drive + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddFolderPayload' + /SASjsApi/drive/rename: + post: + operationId: Rename + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/FileFolderResponse' + examples: + 'Example 1': + value: { status: success } + '409': + description: 'Folder already exists' + content: + application/json: + schema: + $ref: '#/components/schemas/FileFolderResponse' + examples: + 'Example 1': + value: { status: failure, message: 'rename request failed.' } + summary: 'Renames a file/folder in SASjs Drive' + tags: + - Drive + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RenamePayload' + /SASjsApi/drive/filetree: + get: + operationId: GetFileTree + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GetFileTreeResponse' + summary: 'Fetch file tree within SASjs Drive.' + tags: + - Drive + security: + - bearerAuth: [] + parameters: [] + /SASjsApi/user: + get: + operationId: GetAllUsers + responses: + '200': + description: Ok + content: + application/json: + schema: + items: + $ref: '#/components/schemas/UserResponse' + type: array + examples: + 'Example 1': + value: + [ + { + id: 123, + username: johnusername, + displayName: John, + isAdmin: false + }, + { + id: 456, + username: starkusername, + displayName: Stark, + isAdmin: true + } + ] + summary: 'Get list of all users (username, displayname). All users can request this.' + tags: + - User + security: + - bearerAuth: [] + parameters: [] + post: + operationId: CreateUser + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetailsResponse' + examples: + 'Example 1': + value: + { + id: 1234, + displayName: 'John Snow', + username: johnSnow01, + isAdmin: false, + isActive: true + } + summary: 'Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.' + tags: + - User + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserPayload' + '/SASjsApi/user/by/username/{username}': + get: + operationId: GetUserByUsername + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetailsResponse' + description: 'Only Admin or user itself will get user autoExec code.' + summary: 'Get user properties - such as group memberships, userName, displayName.' + tags: + - User + security: + - bearerAuth: [] + parameters: + - description: "The User's username" + in: path + name: username + required: true + schema: + type: string + example: johnSnow01 + patch: + operationId: UpdateUserByUsername + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetailsResponse' + examples: + 'Example 1': + value: + { + id: 1234, + displayName: 'John Snow', + username: johnSnow01, + isAdmin: false, + isActive: true + } + summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.' + tags: + - User + security: + - bearerAuth: [] + parameters: + - description: "The User's username" + in: path + name: username + required: true + schema: + type: string + example: johnSnow01 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserPayload' + delete: + operationId: DeleteUserByUsername + responses: + '204': + description: 'No content' + summary: 'Delete a user. Can be performed either by admins, or the user in question.' + tags: + - User + security: + - bearerAuth: [] + parameters: + - description: "The User's username" + in: path + name: username + required: true + schema: + type: string + example: johnSnow01 + requestBody: + required: true + content: + application/json: + schema: + properties: + password: + type: string + type: object + '/SASjsApi/user/{userId}': + get: + operationId: GetUser + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetailsResponse' + description: 'Only Admin or user itself will get user autoExec code.' + summary: 'Get user properties - such as group memberships, userName, displayName.' + tags: + - User + security: + - bearerAuth: [] + parameters: + - description: "The user's identifier" + in: path + name: userId + required: true + schema: + format: double + type: number + example: 1234 + patch: + operationId: UpdateUser + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserDetailsResponse' + examples: + 'Example 1': + value: + { + id: 1234, + displayName: 'John Snow', + username: johnSnow01, + isAdmin: false, + isActive: true + } + summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.' + tags: + - User + security: + - bearerAuth: [] + parameters: + - description: "The user's identifier" + in: path + name: userId + required: true + schema: + format: double + type: number + example: '1234' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserPayload' + delete: + operationId: DeleteUser + responses: + '204': + description: 'No content' + summary: 'Delete a user. Can be performed either by admins, or the user in question.' + tags: + - User + security: + - bearerAuth: [] + parameters: + - description: "The user's identifier" + in: path + name: userId + required: true + schema: + format: double + type: number + example: 1234 + requestBody: + required: true + content: + application/json: + schema: + properties: + password: + type: string + type: object + /SASjsApi/group: + get: + operationId: GetAllGroups + responses: + '200': + description: Ok + content: + application/json: + schema: + items: + $ref: '#/components/schemas/GroupResponse' + type: array + examples: + 'Example 1': + value: + [ + { + groupId: 123, + name: DCGroup, + description: 'This group represents Data Controller Users' + } + ] + summary: 'Get list of all groups (groupName and groupDescription). All users can request this.' + tags: + - Group + security: + - bearerAuth: [] + parameters: [] + post: + operationId: CreateGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + examples: + 'Example 1': + value: + { + groupId: 123, + name: DCGroup, + description: 'This group represents Data Controller Users', + isActive: true, + users: [] + } + summary: 'Create a new group. Admin only.' + tags: + - Group + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GroupPayload' + '/SASjsApi/group/by/groupname/{name}': + get: + operationId: GetGroupByGroupName + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + summary: 'Get list of members of a group (userName). All users can request this.' + tags: + - Group + security: + - bearerAuth: [] + parameters: + - description: "The group's name" + in: path + name: name + required: true + schema: + type: string + '/SASjsApi/group/{groupId}': + get: + operationId: GetGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + summary: 'Get list of members of a group (userName). All users can request this.' + tags: + - Group + security: + - bearerAuth: [] + parameters: + - description: "The group's identifier" + in: path + name: groupId + required: true + schema: + format: double + type: number + example: 1234 + delete: + operationId: DeleteGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + allOf: + - { $ref: '#/components/schemas/IGroup' } + - { properties: { _id: {} }, required: [_id], type: object } + summary: 'Delete a group. Admin task only.' + tags: + - Group + security: + - bearerAuth: [] + parameters: + - description: "The group's identifier" + in: path + name: groupId + required: true + schema: + format: double + type: number + example: 1234 + '/SASjsApi/group/{groupId}/{userId}': + post: + operationId: AddUserToGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + examples: + 'Example 1': + value: + { + groupId: 123, + name: DCGroup, + description: 'This group represents Data Controller Users', + isActive: true, + users: [] + } + summary: 'Add a user to a group. Admin task only.' + tags: + - Group + security: + - bearerAuth: [] + parameters: + - description: "The group's identifier" + in: path + name: groupId + required: true + schema: + format: double + type: number + example: '1234' + - description: "The user's identifier" + in: path + name: userId + required: true + schema: + format: double + type: number + example: '6789' + delete: + operationId: RemoveUserFromGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + examples: + 'Example 1': + value: + { + groupId: 123, + name: DCGroup, + description: 'This group represents Data Controller Users', + isActive: true, + users: [] + } + summary: 'Remove a user to a group. Admin task only.' + tags: + - Group + security: + - bearerAuth: [] + parameters: + - description: "The group's identifier" + in: path + name: groupId + required: true + schema: + format: double + type: number + example: '1234' + - description: "The user's identifier" + in: path + name: userId + required: true + schema: + format: double + type: number + example: '6789' + /SASjsApi/info: + get: + operationId: Info + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/InfoResponse' + examples: + 'Example 1': + value: + { + mode: desktop, + cors: enable, + whiteList: ['http://example.com', 'http://example2.com'], + protocol: http, + runTimes: [sas, js] + } + summary: 'Get server info (mode, cors, whiteList, protocol).' + tags: + - Info + security: [] + parameters: [] + /SASjsApi/info/authorizedRoutes: + get: + operationId: AuthorizedRoutes + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizedRoutesResponse' + examples: + 'Example 1': + value: { URIs: [/AppStream, /SASjsApi/stp/execute] } + summary: 'Get authorized routes.' + tags: + - Info + security: [] + parameters: [] + /SASjsApi/session: + get: + operationId: Session + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + examples: + 'Example 1': + value: + { + id: 123, + username: johnusername, + displayName: John, + isAdmin: false + } + summary: 'Get session info (username).' + tags: + - Session + security: + - bearerAuth: [] + parameters: [] + /SASjsApi/stp/execute: + get: + operationId: ExecuteReturnRaw + responses: + '200': + description: Ok + content: + application/json: + schema: + anyOf: + - { type: string } + - { type: string, format: byte } + description: "Trigger a SAS or JS program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms" + summary: 'Execute a Stored Program, returns raw _webout content.' + tags: + - STP + security: + - bearerAuth: [] + parameters: + - description: 'Location of SAS or JS code' + in: query + name: _program + required: true + schema: + type: string + example: /Projects/myApp/some/program + post: + operationId: ExecuteReturnJson + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/ExecuteReturnJsonResponse' + examples: + 'Example 1': + value: + { + status: success, + _webout: 'webout content', + log: [], + httpHeaders: + { + Content-type: application/zip, + Cache-Control: 'public, max-age=1000' + } + } + description: "Trigger a SAS or JS program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms\n\nThe response will be a JSON object with the following root attributes:\nlog, webout, headers.\n\nThe webout attribute will be nested JSON ONLY if the response-header\ncontains a content-type of application/json AND it is valid JSON.\nOtherwise it will be a stringified version of the webout content." + summary: 'Execute a Stored Program, return a JSON object' + tags: + - STP + security: + - bearerAuth: [] + parameters: + - description: 'Location of SAS or JS code' + in: query + name: _program + required: false + schema: + type: string + example: /Projects/myApp/some/program + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ExecuteReturnJsonPayload' + /: + get: + operationId: Home + responses: + '200': + description: Ok + content: + application/json: + schema: + type: string + summary: 'Render index.html' + tags: + - Web + security: [] + parameters: [] + /SASLogon/login: + post: + operationId: Login + responses: + '200': + description: Ok + content: + application/json: + schema: + properties: + user: + { + properties: + { + isAdmin: { type: boolean }, + displayName: { type: string }, + username: { type: string }, + id: { type: number, format: double } + }, + required: [isAdmin, displayName, username, id], + type: object + } + loggedIn: { type: boolean } + required: + - user + - loggedIn + type: object + summary: 'Accept a valid username/password' + tags: + - Web + security: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginPayload' + /SASLogon/authorize: + post: + operationId: Authorize + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizeResponse' + examples: + 'Example 1': + value: { code: someRandomCryptoString } + summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE' + tags: + - Web + security: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizePayload' + /SASLogon/logout: + get: + operationId: Logout + responses: + '200': + description: Ok + content: + application/json: + schema: {} + summary: 'Destroy the session stored in cookies' + tags: + - Web + security: [] + parameters: [] + /SASjsApi/permission: + get: + operationId: GetAllPermissions + responses: + '200': + description: Ok + content: + application/json: + schema: + items: + $ref: '#/components/schemas/PermissionDetailsResponse' + type: array + examples: + 'Example 1': + value: + [ + { + permissionId: 123, + uri: /SASjsApi/code/execute, + setting: Grant, + user: + { + id: 1, + username: johnSnow01, + displayName: 'John Snow', + isAdmin: false + } + }, + { + permissionId: 124, + uri: /SASjsApi/code/execute, + setting: Grant, + group: + { + groupId: 1, + name: DCGroup, + description: 'This group represents Data Controller Users', + isActive: true, + users: [] + } + } + ] + summary: 'Get list of all permissions (uri, setting and userDetail).' + tags: + - Permission + security: + - bearerAuth: [] + parameters: [] + post: + operationId: CreatePermission + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionDetailsResponse' + examples: + 'Example 1': + value: + { + permissionId: 123, + uri: /SASjsApi/code/execute, + setting: Grant, + user: + { + id: 1, + username: johnSnow01, + displayName: 'John Snow', + isAdmin: false + } + } + summary: 'Create a new permission. Admin only.' + tags: + - Permission + security: + - bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterPermissionPayload' + '/SASjsApi/permission/{permissionId}': + patch: + operationId: UpdatePermission + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionDetailsResponse' + examples: + 'Example 1': + value: + { + permissionId: 123, + uri: /SASjsApi/code/execute, + setting: Grant, + user: + { + id: 1, + username: johnSnow01, + displayName: 'John Snow', + isAdmin: false + } + } + summary: 'Update permission setting. Admin only' + tags: + - Permission + security: + - bearerAuth: [] + parameters: + - description: "The permission's identifier" + in: path + name: permissionId + required: true + schema: + format: double + type: number + example: 1234 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePermissionPayload' + delete: + operationId: DeletePermission + responses: + '204': + description: 'No content' + summary: 'Delete a permission. Admin only.' + tags: + - Permission + security: + - bearerAuth: [] + parameters: + - description: "The user's identifier" + in: path + name: permissionId + required: true + schema: + format: double + type: number + example: 1234 servers: - - - url: / + - url: / 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: Client - description: 'Operations about clients' - - - name: Auth - description: 'Operations about auth' - - - name: Drive - description: 'Operations on SASjs Drive' - - - name: Group - description: 'Operations on groups and group memberships' - - - name: STP - description: 'Execution of Stored Programs' - - - name: CODE - description: 'Execution of code (various runtimes are supported)' - - - name: Web - description: 'Operations on Web' + - name: Auth + description: 'Operations about auth' + - name: Client + description: 'Operations about clients' + - name: CODE + description: 'Execution of code (various runtimes are supported)' + - name: Drive + description: 'Operations on SASjs Drive' + - 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: User + description: 'Operations with users' + - name: Web + description: 'Operations on Web' diff --git a/api/src/controllers/drive.ts b/api/src/controllers/drive.ts index 3562311..4d0a30c 100644 --- a/api/src/controllers/drive.ts +++ b/api/src/controllers/drive.ts @@ -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({ + @Example({ status: 'success' }) - @Response(403, 'File already exists', { + @Response(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 { + ): Promise { return saveFile((_filePath ?? filePath)!, file) } + /** + * @summary Create an empty folder in SASjs Drive + * + */ + @Example({ + status: 'success' + }) + @Response(409, 'Folder already exists', { + status: 'failure', + message: 'Add folder request failed.' + }) + @Post('/folder') + public async addFolder( + @Body() body: AddFolderPayload + ): Promise { + 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({ + @Example({ status: 'success' }) - @Response(403, `File doesn't exist`, { + @Response(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 { + ): Promise { return updateFile((_filePath ?? filePath)!, file) } + /** + * @summary Renames a file/folder in SASjs Drive + * + */ + @Example({ + status: 'success' + }) + @Response(409, 'Folder already exists', { + status: 'failure', + message: 'rename request failed.' + }) + @Post('/rename') + public async rename( + @Body() body: RenamePayload + ): Promise { + 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 => { + 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 => { + 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) diff --git a/api/src/controllers/internal/Execution.ts b/api/src/controllers/internal/Execution.ts index 7f3541d..1beb046 100644 --- a/api/src/controllers/internal/Execution.ts +++ b/api/src/controllers/internal/Execution.ts @@ -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) diff --git a/api/src/routes/api/drive.ts b/api/src/routes/api/drive.ts index 6126946..e404821 100644 --- a/api/src/routes/api/drive.ts +++ b/api/src/routes/api/drive.ts @@ -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() diff --git a/api/src/routes/api/spec/drive.spec.ts b/api/src/routes/api/spec/drive.spec.ts index 3bf2109..dfc5f8b 100644 --- a/api/src/routes/api/spec/drive.spec.ts +++ b/api/src/routes/api/spec/drive.spec.ts @@ -89,6 +89,12 @@ describe('drive', () => { principalId: dbUser.id, setting: PermissionSetting.grant }) + await permissionController.createPermission({ + uri: '/SASjsApi/drive/rename', + principalType: PrincipalType.user, + principalId: dbUser.id, + setting: PermissionSetting.grant + }) }) afterAll(async () => { @@ -543,29 +549,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 +582,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 +717,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 +732,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 +747,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 +884,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 +905,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 +1012,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 +1056,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 => diff --git a/api/src/types/TreeNode.ts b/api/src/types/TreeNode.ts index 7d902ac..71fd803 100644 --- a/api/src/types/TreeNode.ts +++ b/api/src/types/TreeNode.ts @@ -2,5 +2,6 @@ export interface TreeNode { name: string relativePath: string absolutePath: string + isFolder: boolean children: Array } diff --git a/api/src/utils/getAuthorizedRoutes.ts b/api/src/utils/getAuthorizedRoutes.ts index 93412fa..82f581b 100644 --- a/api/src/utils/getAuthorizedRoutes.ts +++ b/api/src/utils/getAuthorizedRoutes.ts @@ -9,6 +9,7 @@ const StaticAuthorizedRoutes = [ '/SASjsApi/drive/file', '/SASjsApi/drive/folder', '/SASjsApi/drive/fileTree', + '/SASjsApi/drive/rename', '/SASjsApi/permission' ] diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index 0789fa5..09868c7 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -138,9 +138,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 => diff --git a/api/tsoa.json b/api/tsoa.json index 6643337..a3a0c86 100644 --- a/api/tsoa.json +++ b/api/tsoa.json @@ -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", diff --git a/web/package-lock.json b/web/package-lock.json index dbfbda5..41f181a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 8f6681d..95b1874 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/App.tsx b/web/src/App.tsx index 071a8a7..39c11cf 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() {
- - - - - + + } /> + ) @@ -36,23 +33,12 @@ function App() {
- - - - - - - - - - - - - - - - - + + } /> + } /> + } /> + } /> + diff --git a/web/src/containers/Settings/deletePermissionModal.tsx b/web/src/components/deleteConfirmationModal.tsx similarity index 62% rename from web/src/containers/Settings/deletePermissionModal.tsx rename to web/src/components/deleteConfirmationModal.tsx index 23736f8..ec0eb0b 100644 --- a/web/src/containers/Settings/deletePermissionModal.tsx +++ b/web/src/components/deleteConfirmationModal.tsx @@ -18,22 +18,27 @@ const BootstrapDialog = styled(Dialog)(({ theme }) => ({ } })) -type DeleteModalProps = { +type DeleteConfirmationModalProps = { open: boolean setOpen: React.Dispatch> - deletePermission: () => void + message: string + _delete: () => void } -const DeleteModal = ({ open, setOpen, deletePermission }: DeleteModalProps) => { +const DeleteConfirmationModal = ({ + open, + setOpen, + message, + _delete +}: DeleteConfirmationModalProps) => { return ( setOpen(false)} open={open}> - - Are you sure you want to delete this permission? - + {message} - + @@ -41,4 +46,4 @@ const DeleteModal = ({ open, setOpen, deletePermission }: DeleteModalProps) => { ) } -export default DeleteModal +export default DeleteConfirmationModal diff --git a/web/src/components/filePathInputModal.tsx b/web/src/components/filePathInputModal.tsx new file mode 100644 index 0000000..a75dedc --- /dev/null +++ b/web/src/components/filePathInputModal.tsx @@ -0,0 +1,70 @@ +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> + 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) => { + const value = event.target.value + const regex = /\.(exe|sh|htaccess)$/i + if (regex.test(value)) { + setHasError(true) + setErrorText('can not save file with extensions [exe, sh, htaccess]') + } else { + setHasError(false) + setErrorText('') + } + setFilePath(value) + } + + return ( + setOpen(false)} open={open}> + + Save File + + + + + + + + + + ) +} + +export default FilePathInputModal diff --git a/web/src/components/header.tsx b/web/src/components/header.tsx index 35c04ef..96c6bc0 100644 --- a/web/src/components/header.tsx +++ b/web/src/components/header.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useContext } from 'react' -import { Link, useHistory, useLocation } from 'react-router-dom' +import { Link, useNavigate, useLocation } from 'react-router-dom' import { AppBar, @@ -24,7 +24,7 @@ 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( @@ -74,7 +74,7 @@ const Header = (props: any) => { }} onClick={() => { setTabValue('/') - history.push('/') + navigate('/') }} /> { onChange={handleTabChange} > - ({ +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> title: string diff --git a/web/src/components/nameInputModal.tsx b/web/src/components/nameInputModal.tsx new file mode 100644 index 0000000..32e6f52 --- /dev/null +++ b/web/src/components/nameInputModal.tsx @@ -0,0 +1,86 @@ +import React, { useState } 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> + title: string + isFolder: boolean + actionLabel: string + action: (name: string) => void +} + +const NameInputModal = ({ + open, + setOpen, + title, + isFolder, + actionLabel, + action +}: NameInputModalProps) => { + const [name, setName] = useState('') + const [hasError, setHasError] = useState(false) + const [errorText, setErrorText] = useState('') + + const handleChange = (event: React.ChangeEvent) => { + 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) + } + + return ( + setOpen(false)} open={open}> + + {title} + + + + + + + + + + ) +} + +export default NameInputModal diff --git a/web/src/components/tree.tsx b/web/src/components/tree.tsx new file mode 100644 index 0000000..e4d7279 --- /dev/null +++ b/web/src/components/tree.tsx @@ -0,0 +1,242 @@ +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 ( +
    + +
+ ) +} + +export default TreeView + +const TreeViewNode = ({ + node, + selectedFilePath, + handleSelect, + deleteNode, + addFile, + addFolder, + rename, + defaultExpanded +}: Props) => { + const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = + useState(false) + const [deleteConfirmationModalMessage, setDeleteConfirmationModalMessage] = + 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) + } + + const handleNewFileItemClick = () => { + setContextMenu(null) + setNameInputModalOpen(true) + setNameInputModalTitle('Add File') + setNameInputModalActionLabel('Add') + setNameInputModalForFolder(false) + } + + 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) + } + + 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 ( +
+
  • +
    handleItemClick()} + > + {hasChild && + (childVisible ? : )} +
    {node.name}
    +
    + + {hasChild && + childVisible && + node.children.map((child, index) => ( + + ))} +
  • + + + setContextMenu(null)} + anchorReference="anchorPosition" + anchorPosition={ + contextMenu !== null + ? { top: contextMenu.mouseY, left: contextMenu.mouseX } + : undefined + } + > + {node.isFolder && ( +
    + Add Folder + + Add File + +
    + )} + + Rename + + + Delete + +
    +
    + ) +} diff --git a/web/src/containers/Drive/index.tsx b/web/src/containers/Drive/index.tsx deleted file mode 100644 index 854cb9b..0000000 --- a/web/src/containers/Drive/index.tsx +++ /dev/null @@ -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 -} - -const Drive = () => { - const location = useLocation() - const baseUrl = window.location.origin - - const [selectedFilePath, setSelectedFilePath] = useState('') - const [directoryData, setDirectoryData] = useState(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 ( - - - -
    - - ) -} - -export default Drive diff --git a/web/src/containers/Drive/main.tsx b/web/src/containers/Drive/main.tsx deleted file mode 100644 index e3b48c5..0000000 --- a/web/src/containers/Drive/main.tsx +++ /dev/null @@ -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 ( - - - - {isLoading && ( - - )} - {!isLoading && props?.selectedFilePath && !editMode && ( - {fileContent} - )} - {!isLoading && props?.selectedFilePath && editMode && ( - { - if (val) setFileContent(val) - }} - /> - )} - - - - - - {props?.selectedFilePath && ( - - )} - - - ) -} - -export default Main diff --git a/web/src/containers/Drive/sideBar.tsx b/web/src/containers/Drive/sideBar.tsx deleted file mode 100644 index b0aab5b..0000000 --- a/web/src/containers/Drive/sideBar.tsx +++ /dev/null @@ -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) => ( - handleSelect(nodes)} - > - - - } - > - {Array.isArray(nodes.children) - ? nodes.children.map((node) => renderTree(node)) - : null} - - ) - - return ( - - - - {directoryData && ( - } - defaultExpandIcon={} - defaultExpanded={defaultExpanded} - selected={defaultExpanded.slice(-1)} - > - {renderTree(directoryData)} - - )} - - - ) -} - -export default SideBar diff --git a/web/src/containers/Settings/permission.tsx b/web/src/containers/Settings/permission.tsx index 81b2180..772d592 100644 --- a/web/src/containers/Settings/permission.tsx +++ b/web/src/containers/Settings/permission.tsx @@ -28,7 +28,7 @@ import Modal from '../../components/modal' import PermissionFilterModal from './permissionFilterModal' import AddPermissionModal from './addPermissionModal' import UpdatePermissionModal from './updatePermissionModal' -import DeleteModal from './deletePermissionModal' +import DeleteConfirmationModal from '../../components/deleteConfirmationModal' import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar' import { @@ -61,7 +61,10 @@ const Permission = () => { const [addPermissionModalOpen, setAddPermissionModalOpen] = useState(false) const [updatePermissionModalOpen, setUpdatePermissionModalOpen] = useState(false) - const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = + useState(false) + const [deleteConfirmationModalMessage, setDeleteConfirmationModalMessage] = + useState('') const [selectedPermission, setSelectedPermission] = useState() const [filterModalOpen, setFilterModalOpen] = useState(false) @@ -236,11 +239,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}`) @@ -338,10 +344,11 @@ const Permission = () => { permission={selectedPermission} updatePermission={updatePermission} /> - ) diff --git a/web/src/containers/Studio/editor.tsx b/web/src/containers/Studio/editor.tsx new file mode 100644 index 0000000..5742ad2 --- /dev/null +++ b/web/src/containers/Studio/editor.tsx @@ -0,0 +1,607 @@ +import React, { useEffect, useRef, useState, useContext } from 'react' +import axios from 'axios' + +import { + Backdrop, + Box, + Button, + CircularProgress, + FormControl, + IconButton, + Menu, + MenuItem, + Paper, + Select, + SelectChangeEvent, + Tab, + Tooltip +} 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 +} 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 from '../../utils/usePrompt' + +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 +} + +const baseUrl = window.location.origin + +const SASjsEditor = ({ + selectedFilePath, + setSelectedFilePath +}: 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.Success + ) + const [prevFileContent, setPrevFileContent] = useState('') + 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([]) + 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 diffEditorRef = useRef(null as any) + + const handleEditorDidMount: EditorDidMount = (editor) => { + editor.focus() + editorRef.current = editor + } + + const handleDiffEditorDidMount: DiffEditorDidMount = (diffEditor) => { + diffEditor.focus() + diffEditorRef.current = diffEditor + } + + usePrompt( + 'Changes you made may not be saved.', + prevFileContent !== fileContent + ) + + 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 { + setFileContent('') + } + }, [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('2') + + // Scroll to bottom of log + window.scrollTo(0, document.body.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) + + 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) { + setSelectedFilePath(filePath, true) + } + setPrevFileContent(fileContent) + 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 ( + + theme.zIndex.drawer + 1 }} + open={isLoading} + > + + + {selectedFilePath && !runTimes.includes(selectedFileExtension) ? ( + + + + + + {showDiff ? ( + setFileContent(val)} + /> + ) : ( + setFileContent(val)} + /> + )} + + + ) : ( + + + + + + + + + + + + + + + + + + {showDiff ? ( + setFileContent(val)} + /> + ) : ( + setFileContent(val)} + /> + )} +

    + Press CTRL + ENTER to run code +

    +
    +
    + +
    +

    SAS Log

    +
    {log}
    +
    +
    + +
    +
    {webout}
    +
    +
    +
    + )} + + + +
    + ) +} + +export default SASjsEditor + +type RunMenuProps = { + selectedFilePath: string + selectedRunTime: string + runTimes: string[] + handleChangeRunTime: (event: SelectChangeEvent) => void + handleRunBtnClick: () => void +} + +const RunMenu = ({ + selectedFilePath, + selectedRunTime, + runTimes, + handleChangeRunTime, + handleRunBtnClick +}: RunMenuProps) => { + const launchProgram = () => { + window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${selectedFilePath}`) + } + + return ( + <> + + + + {selectedFilePath ? ( + + + + + + + + ) : ( + + + + + + )} + + ) +} + +type FileMenuProps = { + showDiff: boolean + setShowDiff: React.Dispatch> + prevFileContent: string + currentFileContent: string + selectedFilePath: string + setOpenFilePathInputModal: React.Dispatch> + 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 + ) => { + 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 ( + <> + + + + + + handleMenu()} + > + + + + + + + + + + + + ) +} + +const getLanguage = (extension: string) => { + if (extension === 'js') return 'javascript' + + if (extension === 'ts') return 'typescript' + + if (extension === 'md' || extension === 'mdx') return 'markdown' + + return extension +} diff --git a/web/src/containers/Studio/index.tsx b/web/src/containers/Studio/index.tsx index 011ce05..c8f9751 100644 --- a/web/src/containers/Studio/index.tsx +++ b/web/src/containers/Studio/index.tsx @@ -1,253 +1,99 @@ -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([]) - const [selectedRunTime, setSelectedRunTime] = useState('') - const [isRunning, setIsRunning] = useState(false) + const [searchParams, setSearchParams] = useSearchParams() + const [selectedFilePath, setSelectedFilePath] = useState('') + const [directoryData, setDirectoryData] = useState(null) 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 ( - - - - - - - - - - - - - - theme.zIndex.drawer + 1 }} - open={isRunning} - > - - -
    - - - - - - - - -
    - - { - if (val) setFileContent(val) - }} - /> -

    - Press CTRL + ENTER to run SAS code -

    -
    -
    - -
    -

    SAS Log

    -
    {log}
    -
    -
    - -
    -
    {webout}
    -
    -
    -
    + + + + ) } diff --git a/web/src/containers/Studio/sideBar.tsx b/web/src/containers/Studio/sideBar.tsx new file mode 100644 index 0000000..65af0bc --- /dev/null +++ b/web/src/containers/Studio/sideBar.tsx @@ -0,0 +1,196 @@ +import React, { useState, useMemo } from 'react' +import axios from 'axios' +import { Backdrop, Box, CircularProgress, Drawer, Toolbar } from '@mui/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.Success + ) + 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)) + } + + return ( + + theme.zIndex.drawer + 1 }} + open={isLoading} + > + + + + + {directoryData && ( + + )} + + + + + ) +} + +export default SideBar diff --git a/web/src/index.css b/web/src/index.css index 34d605c..6a506a5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; +} diff --git a/web/src/utils/types.ts b/web/src/utils/types.ts index 4f0a80a..99c6fc4 100644 --- a/web/src/utils/types.ts +++ b/web/src/utils/types.ts @@ -30,3 +30,10 @@ export interface RegisterPermissionPayload { principalType: string principalId: number } + +export interface TreeNode { + name: string + relativePath: string + isFolder: boolean + children: Array +} diff --git a/web/src/utils/usePrompt.ts b/web/src/utils/usePrompt.ts new file mode 100644 index 0000000..8c2676c --- /dev/null +++ b/web/src/utils/usePrompt.ts @@ -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' + +function 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 default function usePrompt(message: string, when = true) { + const blocker = useCallback( + (tx) => { + if (window.confirm(message)) tx.retry() + }, + [message] + ) + + useBlocker(blocker, when) +}