diff --git a/Documentation/docs/internal-documentation/backend-services/documents/rest-apis.md b/Documentation/docs/internal-documentation/backend-services/documents/rest-apis.md index 276c39d7c9..975ab485cf 100644 --- a/Documentation/docs/internal-documentation/backend-services/documents/rest-apis.md +++ b/Documentation/docs/internal-documentation/backend-services/documents/rest-apis.md @@ -2,6 +2,16 @@ description: Documents API --- +# Authentication + +All the following routes require the usual authentication header. But you can also use other ways of authentication: + +- For the download routes, you can use a token generated by the `/internal/services/documents/v1/companies/:company_id/download/token` route (see bellow). +- All the routes can use a query string `?public_token=token` to authenticate the user. +- All the routes can use a query string `?twake_tab_token=token` to authenticate the user in the context of a channel tab for instance. + +# Navigation and drive capabilities + ## Fetch a drive item Used to fetch a drive item @@ -73,6 +83,7 @@ Used to fetch a drive item ``` ### Error Responses + If the item cannot be fetched the server will return an error with one of the following status codes: - 401 Unauthorized - The user is not authorized. @@ -86,7 +97,7 @@ Used to create a drive item **Method** : `POST` -**Headers**: `Content-Type: application/json` OR `Content-Type: multipart/form-data` +**Headers**: `Content-Type: application/json` OR `Content-Type: multipart/form-data` **Auth required** : Yes @@ -132,6 +143,7 @@ Used to create a drive item **Code** : `200 OK` ### Error Responses + If the request is missing required fields or the item cannot be created, the server will return an error with one of the following status codes: - 400 Bad Request - The request is missing required fields. @@ -176,6 +188,7 @@ Used to update a drive item **Code** : `200 OK` ### Error Responses + If the request is missing required fields or the item cannot be updated, the server will return an error with one of the following status codes: - 400 Bad Request - The request is missing required fields. @@ -245,6 +258,26 @@ Used to create a drive item version **Code** : `200 OK` +# Download + +## Get a download token + +Before to download, if you can't pass an authorisation token (for example in the browser context) you can generate a token that you will pass in the query string to download the file using the next routes. + +**URL** : `/internal/services/documents/v1/companies/:company_id/item/download/token?items=id1,id2,id3&version_id:optional_id` + +**Method** : `GET` + +**Auth required** : Yes + +### Success Response + +``` +{ + "token": string +} +``` + ## Download Shortcut to download a file (you can also use the file-service directly). @@ -265,3 +298,63 @@ Used to create a zip archive containing the requested drive items ( files and fo **Method** : `GET` **Auth required** : Yes + +# Tabs (for Twake) + +If you want to use the Twake tabs, you must store the configuration of the tabs in the database. + +## Get tab configuration + +Get a tab configuration to get the attached folder/document id. + +**URL** : `/internal/services/documents/v1/companies/:company_id/tabs/:id` + +**Method** : `GET` + +**Auth required** : Yes + +### Success Response + +``` +{ + "company_id": string; + "tab_id": string; + "channel_id": string; + "item_id": string; + "level": "read" | "write"; +} +``` + +## Set tab configuration + +Get a tab configuration to get the attached folder/document id. + +**URL** : `/internal/services/documents/v1/companies/:company_id/tabs/:id` + +**Method** : `POST` + +**Auth required** : Yes + +**Data constraints** : + +``` +{ + "company_id": string; + "tab_id": string; + "channel_id": string; + "item_id": string; + "level": "read" | "write"; +} +``` + +### Success Response + +``` +{ + "company_id": string; + "tab_id": string; + "channel_id": string; + "item_id": string; + "level": "read" | "write"; +} +``` diff --git a/twake/backend/node/src/services/documents/entities/drive-twake-tab.ts b/twake/backend/node/src/services/documents/entities/drive-twake-tab.ts new file mode 100644 index 0000000000..ca8fb6cb36 --- /dev/null +++ b/twake/backend/node/src/services/documents/entities/drive-twake-tab.ts @@ -0,0 +1,30 @@ +import { Type } from "class-transformer"; +import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; + +export const TYPE = "drive_twake_tab"; + +@Entity(TYPE, { + primaryKey: [["company_id"], "tab_id"], + type: TYPE, +}) +export class DriveTwakeTab { + @Type(() => String) + @Column("company_id", "string") + company_id: string; + + @Type(() => String) + @Column("tab_id", "string") + tab_id: string; + + @Type(() => String) + @Column("channel__id", "string") + channel_id: string; + + @Type(() => String) + @Column("item_id", "string") + item_id: string; + + @Type(() => String) + @Column("level", "string") + level: "read" | "write"; +} diff --git a/twake/backend/node/src/services/documents/services/index.ts b/twake/backend/node/src/services/documents/services/index.ts index 673c4e93ed..63941a1f18 100644 --- a/twake/backend/node/src/services/documents/services/index.ts +++ b/twake/backend/node/src/services/documents/services/index.ts @@ -8,6 +8,10 @@ import { hasCompanyAdminLevel } from "../../../utils/company"; import gr from "../../global-resolver"; import { DriveFile, TYPE } from "../entities/drive-file"; import { FileVersion, TYPE as FileVersionType } from "../entities/file-version"; +import { + DriveTwakeTab as DriveTwakeTabEntity, + TYPE as DriveTwakeTabRepoType, +} from "../entities/drive-twake-tab"; import { DriveExecutionContext, DocumentsMessageQueueRequest, @@ -15,6 +19,8 @@ import { RootType, SearchDocumentsOptions, TrashType, + CompanyExecutionContext, + DriveTwakeTab, } from "../types"; import { addDriveItemToArchive, @@ -45,6 +51,7 @@ export class DocumentsService { repository: Repository; searchRepository: SearchRepository; fileVersionRepository: Repository; + driveTwakeTabRepository: Repository; ROOT: RootType = "root"; TRASH: TrashType = "trash"; logger: TwakeLogger = getLogger("Documents Service"); @@ -60,6 +67,11 @@ export class DocumentsService { FileVersionType, FileVersion, ); + this.driveTwakeTabRepository = + await globalResolver.database.getRepository( + DriveTwakeTabRepoType, + DriveTwakeTabEntity, + ); } catch (error) { logger.error("Error while initializing Documents Service", error); } @@ -275,7 +287,7 @@ export class DocumentsService { update = async ( id: string, content: Partial, - context: DriveExecutionContext, + context: CompanyExecutionContext, ): Promise => { if (!context) { this.logger.error("invalid execution context"); @@ -744,4 +756,73 @@ export class DocumentsService { return new ListResult(result.type, filteredResult, result.nextPage); }; + + getTab = async (tabId: string, context: CompanyExecutionContext): Promise => { + const tab = await this.driveTwakeTabRepository.findOne( + { company_id: context.company.id, tab_id: tabId }, + {}, + context, + ); + return tab; + }; + + setTab = async ( + tabId: string, + channelId: string, + itemId: string, + level: "read" | "write", + context: CompanyExecutionContext, + ): Promise => { + const hasAccess = await checkAccess(itemId, null, "manage", this.repository, context); + + if (!hasAccess) { + throw new CrudException("Not enough permissions", 403); + } + + const previousTabConfiguration = await this.getTab(tabId, context); + const item = await this.repository.findOne( + { + company_id: context.company.id, + id: itemId, + }, + {}, + context, + ); + + await this.driveTwakeTabRepository.save( + Object.assign(new DriveTwakeTabEntity(), { + company_id: context.company.id, + tab_id: tabId, + channel_id: channelId, + item_id: itemId, + level, + }), + context, + ); + + await this.update( + item.id, + { + ...item, + access_info: { + ...item.access_info, + entities: [ + ...(item.access_info?.entities || []).filter( + e => + !previousTabConfiguration || + !(e.type === "channel" && e.id !== previousTabConfiguration.channel_id), + ), + { + type: "channel", + id: channelId, + level: level === "write" ? "write" : "read", + }, + ], + }, + }, + context, + ); + + return await this.getTab(tabId, context); + }; } diff --git a/twake/backend/node/src/services/documents/types.ts b/twake/backend/node/src/services/documents/types.ts index 77ebffaed8..79d5f36082 100644 --- a/twake/backend/node/src/services/documents/types.ts +++ b/twake/backend/node/src/services/documents/types.ts @@ -65,3 +65,11 @@ export type exportKeywordPayload = { file_id: string; company_id: string; }; + +export type DriveTwakeTab = { + company_id: string; + tab_id: string; + channel_id: string; + item_id: string; + level: "read" | "write"; +}; diff --git a/twake/backend/node/src/services/documents/utils.ts b/twake/backend/node/src/services/documents/utils.ts index ba781fb351..fad1c2fa43 100644 --- a/twake/backend/node/src/services/documents/utils.ts +++ b/twake/backend/node/src/services/documents/utils.ts @@ -317,7 +317,7 @@ export const checkAccess = async ( item: DriveFile | null, level: DriveFileAccessLevel, repository: Repository, - context: CompanyExecutionContext & { public_token?: string }, + context: CompanyExecutionContext & { public_token?: string; twake_tab_token?: string }, ): Promise => { const grantedLevel = await getAccessLevel(id, item, repository, context); const hasAccess = hasAccessLevel(level, grantedLevel); @@ -341,7 +341,7 @@ export const getAccessLevel = async ( id: string, item: DriveFile | null, repository: Repository, - context: CompanyExecutionContext & { public_token?: string }, + context: CompanyExecutionContext & { public_token?: string; twake_tab_token?: string }, ): Promise => { if (!id || id === "root") return (await isCompanyGuest(context)) ? "read" : "manage"; if (id === "trash") @@ -384,11 +384,17 @@ export const getAccessLevel = async ( if (matchingUser) return matchingUser.level; //Channels - //TODO - const matchingChannel = accessEntities.find( - a => a.type === "channel" && a.id === "TODO for no nothing is set here" && false, - ); - if (matchingChannel) return matchingUser.level; + if (context.twake_tab_token) { + try { + const [channelId] = context.twake_tab_token.split("+"); //First item will be the channel id + const matchingChannel = accessEntities.find( + a => a.type === "channel" && a.id === channelId, + ); + if (matchingChannel) return matchingChannel.level; + } catch (e) { + console.log(e); + } + } const otherLevels = []; diff --git a/twake/backend/node/src/services/documents/web/controllers/documents.ts b/twake/backend/node/src/services/documents/web/controllers/documents.ts index 85d51155c7..9979e28ac5 100644 --- a/twake/backend/node/src/services/documents/web/controllers/documents.ts +++ b/twake/backend/node/src/services/documents/web/controllers/documents.ts @@ -8,8 +8,10 @@ import { PaginationQueryParameters, ResourceWebsocket } from "../../../../utils/ import { DriveFile } from "../../entities/drive-file"; import { FileVersion } from "../../entities/file-version"; import { + CompanyExecutionContext, DriveExecutionContext, DriveItemDetails, + DriveTwakeTab, ItemRequestParams, RequestParams, SearchDocumentsBody, @@ -322,6 +324,38 @@ export class DocumentsController { throw new CrudException("Failed to search for documents", 500); } }; + + getTab = async ( + request: FastifyRequest<{ + Params: { tab_id: string; company_id: string }; + }>, + ): Promise => { + const context = getCompanyExecutionContext(request); + const { tab_id } = request.params; + + return await globalResolver.services.documents.documents.getTab(tab_id, context); + }; + + setTab = async ( + request: FastifyRequest<{ + Params: { tab_id: string; company_id: string }; + Body: DriveTwakeTab; + }>, + ): Promise => { + const context = getCompanyExecutionContext(request); + const { tab_id } = request.params; + + if (!request.body.channel_id || !request.body.item_id) + throw new Error("Missing parameters (channel_id, item_id)"); + + return await globalResolver.services.documents.documents.setTab( + tab_id, + request.body.channel_id, + request.body.item_id, + request.body.level, + context, + ); + }; } /** @@ -341,3 +375,18 @@ const getDriveExecutionContext = ( reqId: req.id, transport: "http", }); + +function getCompanyExecutionContext( + request: FastifyRequest<{ + Params: { company_id: string }; + }>, +): CompanyExecutionContext { + return { + user: request.currentUser, + company: { id: request.params.company_id }, + url: request.url, + method: request.routerMethod, + reqId: request.id, + transport: "http", + }; +} diff --git a/twake/backend/node/src/services/documents/web/routes.ts b/twake/backend/node/src/services/documents/web/routes.ts index baa49e68bf..0bcf1aff83 100644 --- a/twake/backend/node/src/services/documents/web/routes.ts +++ b/twake/backend/node/src/services/documents/web/routes.ts @@ -80,6 +80,20 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next) handler: documentsController.search.bind(documentsController), }); + fastify.route({ + method: "GET", + url: `${baseUrl}/tabs/:tab_id`, + preValidation: [fastify.authenticate], + handler: documentsController.getTab.bind(documentsController), + }); + + fastify.route({ + method: "POST", + url: `${baseUrl}/tabs/:tab_id`, + preValidation: [fastify.authenticate], + handler: documentsController.setTab.bind(documentsController), + }); + return next(); }; diff --git a/twake/backend/node/test/e2e/documents/documents-tab.spec.ts b/twake/backend/node/test/e2e/documents/documents-tab.spec.ts new file mode 100644 index 0000000000..e9d6c84e11 --- /dev/null +++ b/twake/backend/node/test/e2e/documents/documents-tab.spec.ts @@ -0,0 +1,183 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "@jest/globals"; +import { deserialize } from "class-transformer"; +import { AccessInformation } from "../../../src/services/documents/entities/drive-file"; +import { init, TestPlatform } from "../setup"; +import { TestDbService } from "../utils.prepare.db"; +import { e2e_createDocument, e2e_getDocument } from "./utils"; + +const url = "/internal/services/documents/v1"; + +describe("the Drive Twake tabs feature", () => { + let platform: TestPlatform; + + class DriveFileMockClass { + id: string; + name: string; + size: number; + added: string; + parent_id: string; + access_info: AccessInformation; + } + + class DriveItemDetailsMockClass { + path: string[]; + item: DriveFileMockClass; + children: DriveFileMockClass[]; + versions: Record[]; + } + + beforeEach(async () => { + platform = await init({ + services: [ + "webserver", + "database", + "applications", + "search", + "storage", + "message-queue", + "user", + "search", + "files", + "websocket", + "messages", + "auth", + "realtime", + "channels", + "counter", + "statistics", + "platform-services", + "documents", + ], + }); + }); + + afterEach(async () => { + await platform.tearDown(); + }); + + afterAll(async () => { + await platform.app.close(); + }); + + it("did create a tab configuration on Drive side", async done => { + await TestDbService.getInstance(platform, true); + + const item = { + name: "new tab test file", + parent_id: "root", + company_id: platform.workspace.company_id, + }; + + const version = {}; + + const response = await e2e_createDocument(platform, item, version); + const doc = deserialize(DriveFileMockClass, response.body); + + const tab = { + company_id: platform.workspace.company_id, + tab_id: "1234567890", + channel_id: "abcdefghij", + item_id: doc.id, + level: "write", + }; + + const token = await platform.auth.getJWTToken(); + + const createdTab = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${platform.workspace.company_id}/tabs/${tab.tab_id}`, + headers: { + authorization: `Bearer ${token}`, + }, + payload: tab, + }); + + expect(createdTab.statusCode).toBe(200); + expect(createdTab.body).toBeDefined(); + expect(createdTab.json().company_id).toBe(tab.company_id); + expect(createdTab.json().tab_id).toBe(tab.tab_id); + expect(createdTab.json().item_id).toBe(tab.item_id); + + const getTabResponse = await platform.app.inject({ + method: "GET", + url: `${url}/companies/${platform.workspace.company_id}/tabs/${tab.tab_id}`, + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(getTabResponse.statusCode).toBe(200); + expect(getTabResponse.body).toBeDefined(); + expect(getTabResponse.json().company_id).toBe(tab.company_id); + expect(getTabResponse.json().tab_id).toBe(tab.tab_id); + expect(getTabResponse.json().item_id).toBe(tab.item_id); + + const documentResponse = await e2e_getDocument(platform, doc.id); + const documentResult = deserialize( + DriveItemDetailsMockClass, + documentResponse.body, + ); + + console.log(documentResult?.item); + + expect( + documentResult?.item?.access_info?.entities?.find( + a => a?.type === "channel" && a.id === "abcdefghij" && a.level === "write", + ), + ).toBeDefined(); + + done?.(); + }); + + it("did refuse to create a tab configuration for an item I can't manage", async done => { + const dbService = await TestDbService.getInstance(platform, true); + const ws0pk = { + id: platform.workspace.workspace_id, + company_id: platform.workspace.company_id, + }; + const otherUser = await dbService.createUser([ws0pk]); + + const item = { + name: "new tab test file", + parent_id: "root", + company_id: platform.workspace.company_id, + access_info: { + entities: [ + { + type: "folder", + id: "parent", + level: "none", + } as any, + ], + }, + }; + + const version = {}; + + const response = await e2e_createDocument(platform, item, version); + const doc = deserialize(DriveFileMockClass, response.body); + + const tab = { + company_id: platform.workspace.company_id, + tab_id: "1234567890", + channel_id: "abcdefghij", + item_id: doc.id, + level: "read", + }; + + const token = await platform.auth.getJWTToken({ sub: otherUser.id }); + + const createdTab = await platform.app.inject({ + method: "POST", + url: `${url}/companies/${platform.workspace.company_id}/tabs/${tab.tab_id}`, + headers: { + authorization: `Bearer ${token}`, + }, + payload: tab, + }); + + expect(createdTab.statusCode).toBe(403); + + done?.(); + }); +}); diff --git a/twake/frontend/src/app/components/search-popup/parts/drive-item-result.tsx b/twake/frontend/src/app/components/search-popup/parts/drive-item-result.tsx index 3f8b5b36d4..2e212aa9ac 100644 --- a/twake/frontend/src/app/components/search-popup/parts/drive-item-result.tsx +++ b/twake/frontend/src/app/components/search-popup/parts/drive-item-result.tsx @@ -25,16 +25,17 @@ import { useRecoilValue, useRecoilState } from 'recoil'; import { openDriveItem, onDriveItemDownloadClick } from '../common'; import ResultContext from './result-context'; import { useCompanyApplications } from 'app/features/applications/hooks/use-company-applications'; -import { DriveCurrentFolderAtom } from 'app/views/applications/drive/index'; +import { DriveCurrentFolderAtom } from 'app/views/applications/drive/browser'; export default (props: { driveItem: DriveItem & { user?: UserType } }) => { const input = useRecoilValue(SearchInputState); const currentWorkspaceId = useRouterWorkspace(); const companyApplications = useCompanyApplications(); - const [_, setParentId] = useRecoilState(DriveCurrentFolderAtom("root")); - const twakeDriveApplicationId = companyApplications.applications.find((application) => { - return application.identity.code === "twake_drive"; - })?.id || ''; + const [_, setParentId] = useRecoilState(DriveCurrentFolderAtom('root')); + const twakeDriveApplicationId = + companyApplications.applications.find(application => { + return application.identity.code === 'twake_drive'; + })?.id || ''; const file = props.driveItem; const name = file?.name; const extension = name?.split('.').pop(); @@ -93,13 +94,11 @@ export default (props: { driveItem: DriveItem & { user?: UserType } }) => { diff --git a/twake/frontend/src/app/features/drive-twake/api-client/api-client.ts b/twake/frontend/src/app/features/drive-twake/api-client/api-client.ts new file mode 100644 index 0000000000..c635bf9e8a --- /dev/null +++ b/twake/frontend/src/app/features/drive-twake/api-client/api-client.ts @@ -0,0 +1,29 @@ +import Api from '../../global/framework/api-service'; +import { DriveTwakeTab } from '../types'; + +export class DriveTwakeApiClient { + static async getTab(companyId: string, tabId: string) { + return await Api.get( + `/internal/services/documents/v1/companies/${companyId}/tab/${tabId}`, + ); + } + + static async setTab( + companyId: string, + tabId: string, + channelId: string, + itemId: string, + level: 'write' | 'read', + ) { + return await Api.post( + `/internal/services/documents/v1/companies/${companyId}/tab/${tabId}`, + { + company_id: companyId, + tab_id: tabId, + channel_id: channelId, + item_id: itemId, + level, + }, + ); + } +} diff --git a/twake/frontend/src/app/features/drive-twake/hooks/use-drive-twake-tab.tsx b/twake/frontend/src/app/features/drive-twake/hooks/use-drive-twake-tab.tsx new file mode 100644 index 0000000000..b36bce817f --- /dev/null +++ b/twake/frontend/src/app/features/drive-twake/hooks/use-drive-twake-tab.tsx @@ -0,0 +1,29 @@ +import useRouterCompany from 'app/features/router/hooks/use-router-company'; +import { useEffect, useState } from 'react'; +import { useRecoilState } from 'recoil'; +import { DriveTwakeApiClient } from '../api-client/api-client'; +import { DriveTwakeTabAtom } from '../state/store'; + +export const useDriveTwakeTab = (channelId: string, tabId: string) => { + const companyId = useRouterCompany(); + const [tab, setTab] = useRecoilState(DriveTwakeTabAtom(tabId)); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + DriveTwakeApiClient.getTab(companyId, tabId) + .then(setTab) + .finally(() => setLoading(false)); + }, [companyId, tabId]); + + return { + tab, + setTab: async (itemId: string, level: 'read' | 'write') => { + setLoading(true); + const tab = await DriveTwakeApiClient.setTab(companyId, tabId, channelId, itemId, level); + if (tab.item_id) setTab(tab); + setLoading(false); + }, + loading, + }; +}; diff --git a/twake/frontend/src/app/features/drive-twake/state/store.ts b/twake/frontend/src/app/features/drive-twake/state/store.ts new file mode 100644 index 0000000000..bb3ef8a82c --- /dev/null +++ b/twake/frontend/src/app/features/drive-twake/state/store.ts @@ -0,0 +1,7 @@ +import { atomFamily } from 'recoil'; +import { DriveTwakeTab } from '../types'; + +export const DriveTwakeTabAtom = atomFamily({ + key: 'DriveTwakeTabAtom', + default: () => null, +}); diff --git a/twake/frontend/src/app/features/drive-twake/types.ts b/twake/frontend/src/app/features/drive-twake/types.ts new file mode 100644 index 0000000000..d43eb00a08 --- /dev/null +++ b/twake/frontend/src/app/features/drive-twake/types.ts @@ -0,0 +1,7 @@ +export type DriveTwakeTab = { + company_id: string; + tab_id: string; + channel_id: string; + item_id: string; + level: 'write' | 'read'; +}; diff --git a/twake/frontend/src/app/features/drive/api-client/api-client.ts b/twake/frontend/src/app/features/drive/api-client/api-client.ts index 0bc82064b2..0de0278f93 100644 --- a/twake/frontend/src/app/features/drive/api-client/api-client.ts +++ b/twake/frontend/src/app/features/drive/api-client/api-client.ts @@ -18,15 +18,23 @@ export type SearchDocumentsBody = { }; let publicLinkToken: null | string = null; +let twakeTabToken: null | string = null; export const setPublicLinkToken = (token: string | null) => { publicLinkToken = token; }; -const appendPublicToken = (useAnd?: boolean) => { +export const setTwakeTabToken = (token: string | null) => { + twakeTabToken = token; +}; + +const appendPublicAndTwakeToken = (useAnd?: boolean) => { if (publicLinkToken) { return `${useAnd ? '&' : '?'}public_token=${publicLinkToken}`; } + if (twakeTabToken) { + return `${useAnd ? '&' : '?'}twake_tab_token=${twakeTabToken}`; + } return ''; }; @@ -34,19 +42,19 @@ export class DriveApiClient { private static logger = Logger.getLogger('MessageAPIClientService'); static async get(companyId: string, id: string | 'trash' | '') { return await Api.get( - `/internal/services/documents/v1/companies/${companyId}/item/${id}${appendPublicToken()}`, + `/internal/services/documents/v1/companies/${companyId}/item/${id}${appendPublicAndTwakeToken()}`, ); } static async remove(companyId: string, id: string | 'trash' | '') { return await Api.delete( - `/internal/services/documents/v1/companies/${companyId}/item/${id}${appendPublicToken()}`, + `/internal/services/documents/v1/companies/${companyId}/item/${id}${appendPublicAndTwakeToken()}`, ); } static async update(companyId: string, id: string, update: Partial) { return await Api.post, DriveItem>( - `/internal/services/documents/v1/companies/${companyId}/item/${id}${appendPublicToken()}`, + `/internal/services/documents/v1/companies/${companyId}/item/${id}${appendPublicAndTwakeToken()}`, update, ); } @@ -60,14 +68,14 @@ export class DriveApiClient { { item: Partial; version: Partial }, DriveItem >( - `/internal/services/documents/v1/companies/${companyId}/item${appendPublicToken()}`, + `/internal/services/documents/v1/companies/${companyId}/item${appendPublicAndTwakeToken()}`, data as { item: Partial; version: Partial }, ); } static async createVersion(companyId: string, id: string, version: Partial) { return await Api.post, DriveItemVersion>( - `/internal/services/documents/v1/companies/${companyId}/item/${id}/version${appendPublicToken()}`, + `/internal/services/documents/v1/companies/${companyId}/item/${id}/version${appendPublicAndTwakeToken()}`, version, ); } @@ -76,7 +84,7 @@ export class DriveApiClient { return Api.get<{ token: string }>( `/internal/services/documents/v1/companies/${companyId}/item/download/token` + `?items=${ids.join(',')}&version_id=${versionId}` + - appendPublicToken(true), + appendPublicAndTwakeToken(true), ); } @@ -84,12 +92,12 @@ export class DriveApiClient { const { token } = await DriveApiClient.getDownloadToken(companyId, [id], versionId); if (versionId) return Api.route( - `/internal/services/documents/v1/companies/${companyId}/item/${id}/download?version_id=${versionId}&token=${token}${appendPublicToken( + `/internal/services/documents/v1/companies/${companyId}/item/${id}/download?version_id=${versionId}&token=${token}${appendPublicAndTwakeToken( true, )}`, ); return Api.route( - `/internal/services/documents/v1/companies/${companyId}/item/${id}/download?token=${token}${appendPublicToken( + `/internal/services/documents/v1/companies/${companyId}/item/${id}/download?token=${token}${appendPublicAndTwakeToken( true, )}`, ); @@ -100,7 +108,7 @@ export class DriveApiClient { return Api.route( `/internal/services/documents/v1/companies/${companyId}/item/download/zip` + `?items=${ids.join(',')}&token=${token}` + - appendPublicToken(true), + appendPublicAndTwakeToken(true), ); } diff --git a/twake/frontend/src/app/features/drive/hooks/use-drive-item.tsx b/twake/frontend/src/app/features/drive/hooks/use-drive-item.tsx index f8c9f2f794..26e271efe8 100644 --- a/twake/frontend/src/app/features/drive/hooks/use-drive-item.tsx +++ b/twake/frontend/src/app/features/drive/hooks/use-drive-item.tsx @@ -1,12 +1,13 @@ import { ToasterService } from 'app/features/global/services/toaster-service'; import { LoadingStateInitTrue } from 'app/features/global/state/atoms/Loading'; import useRouterCompany from 'app/features/router/hooks/use-router-company'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { DriveItemAtom, DriveItemChildrenAtom } from '../state/store'; import { DriveItem } from '../types'; import { useDriveActions } from './use-drive-actions'; import { useDriveUpload } from './use-drive-upload'; +import short from 'short-uuid'; /** * Get in store single item and expose methods to operate on it @@ -87,3 +88,14 @@ export const useDriveItem = (id: string) => { refresh, }; }; + +export const usePublicLink = (item?: DriveItem) => { + const translator = useRef(short()).current; + const publicLink = + `${document.location.protocol}//${document.location.host}` + + `/shared/${translator.fromUUID(item?.company_id || '')}` + + `/drive/${translator.fromUUID(item?.id || '')}` + + `/t/${item?.access_info?.public?.token}`; + + return publicLink; +}; diff --git a/twake/frontend/src/app/features/drive/hooks/use-drive-upload.tsx b/twake/frontend/src/app/features/drive/hooks/use-drive-upload.tsx index bf3b997efc..d6332c71a9 100644 --- a/twake/frontend/src/app/features/drive/hooks/use-drive-upload.tsx +++ b/twake/frontend/src/app/features/drive/hooks/use-drive-upload.tsx @@ -1,5 +1,6 @@ import { FileTreeObject } from 'app/components/uploads/file-tree-utils'; import FileUploadService from 'app/features/files/services/file-upload-service'; +import { ToasterService } from 'app/features/global/services/toaster-service'; import { DriveApiClient } from '../api-client/api-client'; import { useDriveActions } from './use-drive-actions'; @@ -85,7 +86,7 @@ export const useDriveUpload = () => { create( { company_id: context.companyId, - workspace_id: 'someid', + workspace_id: 'drive', //We don't set workspace ID for now parent_id: context.parentId, name: file.metadata?.name, size: file.upload_data?.size, @@ -109,5 +110,51 @@ export const useDriveUpload = () => { } }; - return { uploadTree, uploadVersion }; + const uploadFromUrl = + (url: string, name: string, context: { companyId: string; parentId: string }) => () => { + const request = new XMLHttpRequest(); + request.open('GET', url, true); + request.responseType = 'blob'; + request.onload = function () { + try { + const file = new File([request.response], name); + FileUploadService.upload([file], { + context: { + companyId: context.companyId, + parentId: context.parentId, + }, + callback: (file, context) => { + if (file) { + create( + { + company_id: context.companyId, + workspace_id: 'drive', //We don't set workspace ID for now + parent_id: context.parentId, + name: file.metadata?.name, + size: file.upload_data?.size, + }, + { + provider: 'internal', + application_id: '', + file_metadata: { + name: file.metadata?.name, + size: file.upload_data?.size, + mime: file.metadata?.mime, + thumbnails: file?.thumbnails, + source: 'internal', + external_id: file.id, + }, + }, + ); + } + }, + }); + } catch (e) { + ToasterService.error('Error while creating an empty file.'); + } + }; + request.send(); + }; + + return { uploadTree, uploadFromUrl, uploadVersion }; }; diff --git a/twake/frontend/src/app/views/applications/drive/browser.tsx b/twake/frontend/src/app/views/applications/drive/browser.tsx new file mode 100644 index 0000000000..8c622fd919 --- /dev/null +++ b/twake/frontend/src/app/views/applications/drive/browser.tsx @@ -0,0 +1,333 @@ +import { ChevronDownIcon } from '@heroicons/react/outline'; +import { Button } from 'app/atoms/button/button'; +import { Base, BaseSmall, Subtitle, Title } from 'app/atoms/text'; +import Menu from 'app/components/menus/menu'; +import { getFilesTree } from 'app/components/uploads/file-tree-utils'; +import UploadZone from 'app/components/uploads/upload-zone'; +import { setTwakeTabToken } from 'app/features/drive/api-client/api-client'; +import { useDriveActions } from 'app/features/drive/hooks/use-drive-actions'; +import { useDriveItem } from 'app/features/drive/hooks/use-drive-item'; +import { useDriveRealtime } from 'app/features/drive/hooks/use-drive-realtime'; +import { useDriveUpload } from 'app/features/drive/hooks/use-drive-upload'; +import { DriveItemSelectedList } from 'app/features/drive/state/store'; +import { formatBytes } from 'app/features/drive/utils'; +import useRouterCompany from 'app/features/router/hooks/use-router-company'; +import _ from 'lodash'; +import { Suspense, useCallback, useEffect, useRef } from 'react'; +import { atomFamily, useRecoilState, useSetRecoilState } from 'recoil'; +import { DrivePreview } from '../viewer/drive-preview'; +import HeaderPath from './header-path'; +import { DocumentRow } from './item-row/document-row'; +import { FolderRow } from './item-row/folder-row'; +import { ConfirmDeleteModal, ConfirmDeleteModalAtom } from './modals/confirm-delete'; +import { ConfirmTrashModal, ConfirmTrashModalAtom } from './modals/confirm-trash'; +import { CreateModal, CreateModalAtom } from './modals/create'; +import { PropertiesModal } from './modals/properties'; +import { SelectorModalAtom } from './modals/selector'; +import { AccessModal } from './modals/update-access'; +import { VersionsModal } from './modals/versions'; + +export const DriveCurrentFolderAtom = atomFamily({ + key: 'DriveCurrentFolderAtom', + default: startingParentId => startingParentId || 'root', +}); + +export default ({ + initialParentId, + twakeTabContextToken, +}: { + initialParentId?: string; + twakeTabContextToken?: string; +}) => { + const companyId = useRouterCompany(); + setTwakeTabToken(twakeTabContextToken || null); + + const [parentId, setParentId] = useRecoilState(DriveCurrentFolderAtom(initialParentId || 'root')); + + const { download, downloadZip, update } = useDriveActions(); + const { access, item, inTrash, refresh, children, loading, path } = useDriveItem(parentId); + const { item: trash } = useDriveItem('trash'); + const { uploadTree, uploadFromUrl } = useDriveUpload(); + useDriveRealtime(parentId); + + const uploadZone = 'drive_' + companyId; + const uploadZoneRef = useRef(null); + + const setCreationModalState = useSetRecoilState(CreateModalAtom); + const setSelectorModalState = useSetRecoilState(SelectorModalAtom); + const setConfirmDeleteModalState = useSetRecoilState(ConfirmDeleteModalAtom); + const setConfirmTrashModalState = useSetRecoilState(ConfirmTrashModalAtom); + const [checked, setChecked] = useRecoilState(DriveItemSelectedList); + + useEffect(() => { + setChecked({}); + refresh(parentId); + refresh('trash'); + }, [parentId, refresh]); + + const openItemModal = useCallback(() => { + if (item?.id) setCreationModalState({ open: true, parent_id: item.id }); + }, [item?.id, setCreationModalState]); + + const selectedCount = Object.values(checked).filter(v => v).length; + const folders = children.filter(i => i.is_directory).sort((a, b) => a.name.localeCompare(b.name)); + const documents = ( + item?.is_directory === false + ? //We use this hack for public shared single file + item + ? [item] + : [] + : children + ) + .filter(i => !i.is_directory) + .sort((a, b) => a.name.localeCompare(b.name)); + return ( + { + const tree = await getFilesTree(event); + setCreationModalState({ parent_id: '', open: false }); + uploadTree(tree, { + companyId, + parentId, + }); + }} + > + uploadZoneRef.current?.open()} + addFromUrl={(url, name) => + uploadFromUrl(url, name, { + companyId, + parentId, + }) + } + /> + + + + + + }> + + + +
+
+ +
+ {access !== 'read' && ( + {formatBytes(item?.size || 0)} used in this folder + )} + + setSelectorModalState({ + open: true, + parent_id: inTrash ? 'root' : parentId, + title: 'Move ' + selectedCount + ' items', + mode: 'move', + onSelected: async ids => { + for (const item of children.filter(c => checked[c.id])) { + await update( + { + parent_id: ids[0], + }, + item.id, + item.parent_id, + ); + } + setChecked({}); + }, + }), + }, + { + type: 'menu', + text: 'Download ' + selectedCount + ' items', + onClick: () => + selectedCount === 1 + ? download(Object.keys(checked)[0]) + : downloadZip(Object.keys(checked)), + }, + { type: 'separator', hide: access === 'read' }, + { + type: 'menu', + text: 'Delete ' + selectedCount + ' items', + hide: !inTrash || access !== 'manage', + className: 'error', + onClick: () => { + setConfirmDeleteModalState({ + open: true, + items: children.filter(a => checked[a.id]), + }); + }, + }, + { + type: 'menu', + text: 'Move ' + selectedCount + ' items to trash', + hide: inTrash || access === 'read', + className: 'error', + onClick: async () => + setConfirmTrashModalState({ + open: true, + items: children.filter(a => checked[a.id]), + }), + }, + ] + : inTrash + ? [ + { + type: 'menu', + text: 'Exit trash', + onClick: () => setParentId('root'), + }, + { type: 'separator' }, + { + type: 'menu', + text: 'Empty trash', + className: 'error', + hide: parentId != 'trash' || access !== 'manage', + onClick: () => { + setConfirmDeleteModalState({ + open: true, + items: children, //Fixme: Here it works because this menu is displayed only in the trash root folder + }); + }, + }, + ] + : [ + { + type: 'menu', + text: 'Download folder', + hide: inTrash, + onClick: () => downloadZip([parentId]), + }, + { + type: 'menu', + text: 'Add document or folder', + hide: inTrash || access === 'read', + onClick: () => openItemModal(), + }, + { type: 'separator' }, + { + type: 'menu', + text: 'Go to trash', + hide: inTrash || access === 'read', + onClick: () => setParentId('trash'), + }, + ] + } + > + {' '} + + +
+ + {item?.id === 'trash' && ( +
+ You are in the trash +
+ +
+
+ )} + {access !== 'read' && item?.id === 'root' && ( +
+ Welcome to your company drive. +
+ + {formatBytes(item?.size || 0)} + <Base> used, </Base> <Base>{formatBytes(trash?.size || 0)} in trash</Base> + +
+
+ )} + {item?.id !== 'root' && item?.id !== 'trash' &&
} + +
+ {folders.length > 0 && ( + <> + Folders + + {folders.map((item, index) => ( + { + return setParentId(item.id); + }} + checked={checked[item.id] || false} + onCheck={v => setChecked(_.pickBy({ ...checked, [item.id]: v }, _.identity))} + parentAccess={access} + /> + ))} +
+ + )} + + Documents + + {documents.length === 0 && !loading && ( +
+ Nothing here. + {!inTrash && access != 'read' && ( + <> + + Drag and drop files to upload them or click on the 'Add document' button. + +
+ + + )} +
+ )} + + {documents.map((item, index) => ( + {}} + item={item} + checked={checked[item.id] || false} + onCheck={v => setChecked(_.pickBy({ ...checked, [item.id]: v }, _.identity))} + parentAccess={access} + /> + ))} +
+
+ + ); +}; diff --git a/twake/frontend/src/app/views/applications/drive/index.tsx b/twake/frontend/src/app/views/applications/drive/index.tsx index 4d23cfb1db..5e6e068367 100644 --- a/twake/frontend/src/app/views/applications/drive/index.tsx +++ b/twake/frontend/src/app/views/applications/drive/index.tsx @@ -1,319 +1,39 @@ -import { ChevronDownIcon } from '@heroicons/react/outline'; -import { Button } from 'app/atoms/button/button'; -import { Base, BaseSmall, Subtitle, Title } from 'app/atoms/text'; -import Menu from 'app/components/menus/menu'; -import { getFilesTree } from 'app/components/uploads/file-tree-utils'; -import UploadZone from 'app/components/uploads/upload-zone'; -import { useDriveActions } from 'app/features/drive/hooks/use-drive-actions'; -import { useDriveItem } from 'app/features/drive/hooks/use-drive-item'; -import { useDriveRealtime } from 'app/features/drive/hooks/use-drive-realtime'; -import { useDriveUpload } from 'app/features/drive/hooks/use-drive-upload'; -import { DriveItemSelectedList } from 'app/features/drive/state/store'; -import { formatBytes } from 'app/features/drive/utils'; -import useRouterCompany from 'app/features/router/hooks/use-router-company'; -import _ from 'lodash'; -import { Suspense, useCallback, useEffect, useRef } from 'react'; -import { atomFamily, useRecoilState, useSetRecoilState } from 'recoil'; -import { DrivePreview } from '../viewer/drive-preview'; -import HeaderPath from './header-path'; -import { DocumentRow } from './item-row/document-row'; -import { FolderRow } from './item-row/folder-row'; -import { ConfirmDeleteModal, ConfirmDeleteModalAtom } from './modals/confirm-delete'; -import { ConfirmTrashModal, ConfirmTrashModalAtom } from './modals/confirm-trash'; -import { CreateModal, CreateModalAtom } from './modals/create'; -import { PropertiesModal } from './modals/properties'; -import { SelectorModal, SelectorModalAtom } from './modals/selector'; -import { AccessModal } from './modals/update-access'; -import { VersionsModal } from './modals/versions'; - -export const DriveCurrentFolderAtom = atomFamily({ - key: 'DriveCurrentFolderAtom', - default: startingParentId => startingParentId || 'root', -}); - -export default ({ initialParentId }: { initialParentId?: string }) => { - const companyId = useRouterCompany(); - - const [parentId, setParentId] = useRecoilState(DriveCurrentFolderAtom(initialParentId || 'root')); - - const { download, downloadZip, update } = useDriveActions(); - const { access, item, inTrash, refresh, children, loading, path } = useDriveItem(parentId); - const { item: trash } = useDriveItem('trash'); - const { uploadTree } = useDriveUpload(); - useDriveRealtime(parentId); - - const uploadZone = 'drive_' + companyId; - const uploadZoneRef = useRef(null); - - const setCreationModalState = useSetRecoilState(CreateModalAtom); - const setSelectorModalState = useSetRecoilState(SelectorModalAtom); - const setConfirmDeleteModalState = useSetRecoilState(ConfirmDeleteModalAtom); - const setConfirmTrashModalState = useSetRecoilState(ConfirmTrashModalAtom); - const [checked, setChecked] = useRecoilState(DriveItemSelectedList); - - useEffect(() => { - setChecked({}); - refresh(parentId); - refresh('trash'); - }, [parentId, refresh]); - - const openItemModal = useCallback(() => { - if (item?.id) setCreationModalState({ open: true, parent_id: item.id }); - }, [item?.id, setCreationModalState]); - - const canWrite = true; // TODO get write permission from backend +import Browser from './browser'; +import { SelectorModal } from './modals/selector'; +import TwakeTabConfiguration from './twake-tab-configuration'; + +export type EmbedContext = { + companyId?: string; + workspaceId?: string; + channelId?: string; + tabId?: string; +}; - const selectedCount = Object.values(checked).filter(v => v).length; - const folders = children.filter(i => i.is_directory).sort((a, b) => a.name.localeCompare(b.name)); - const documents = ( - item?.is_directory === false - ? //We use this hack for public shared single file - item - ? [item] - : [] - : children - ) - .filter(i => !i.is_directory) - .sort((a, b) => a.name.localeCompare(b.name)); +export default ({ + initialParentId, + context, +}: { + initialParentId?: string; + context?: EmbedContext; +}) => { return ( - { - const tree = await getFilesTree(event); - setCreationModalState({ parent_id: '', open: false }); - uploadTree(tree, { - companyId, - parentId, - }); - }} - > - uploadZoneRef.current?.open()} /> - + <> - - - - - }> - - -
-
- -
- {access !== 'read' && ( - {formatBytes(item?.size || 0)} used in this folder - )} - - setSelectorModalState({ - open: true, - parent_id: inTrash ? 'root' : parentId, - title: 'Move ' + selectedCount + ' items', - mode: 'move', - onSelected: async ids => { - for (const item of children.filter(c => checked[c.id])) { - await update( - { - parent_id: ids[0], - }, - item.id, - item.parent_id, - ); - } - setChecked({}); - }, - }), - }, - { - type: 'menu', - text: 'Download ' + selectedCount + ' items', - onClick: () => - selectedCount === 1 - ? download(Object.keys(checked)[0]) - : downloadZip(Object.keys(checked)), - }, - { type: 'separator' }, - { - type: 'menu', - text: 'Delete ' + selectedCount + ' items', - hide: !inTrash || access !== 'manage', - className: 'error', - onClick: () => { - setConfirmDeleteModalState({ - open: true, - items: children.filter(a => checked[a.id]), - }); - }, - }, - { - type: 'menu', - text: 'Move ' + selectedCount + ' items to trash', - hide: inTrash || access === 'read', - className: 'error', - onClick: async () => - setConfirmTrashModalState({ - open: true, - items: children.filter(a => checked[a.id]), - }), - }, - ] - : inTrash - ? [ - { - type: 'menu', - text: 'Exit trash', - onClick: () => setParentId('root'), - }, - { type: 'separator' }, - { - type: 'menu', - text: 'Empty trash', - className: 'error', - hide: parentId != 'trash' || access !== 'manage', - onClick: () => { - setConfirmDeleteModalState({ - open: true, - items: children, //Fixme: Here it works because this menu is displayed only in the trash root folder - }); - }, - }, - ] - : [ - { - type: 'menu', - text: 'Download folder', - hide: inTrash, - onClick: () => downloadZip([parentId]), - }, - { - type: 'menu', - text: 'Add document or folder', - hide: inTrash || access === 'read', - onClick: () => openItemModal(), - }, - { type: 'separator' }, - { - type: 'menu', - text: 'Go to trash', - hide: inTrash || access === 'read', - onClick: () => setParentId('trash'), - }, - ] - } - > - {' '} - - -
- - {item?.id === 'trash' && ( -
- You are in the trash -
- -
-
- )} - {access !== 'read' && item?.id === 'root' && ( -
- Welcome to your company drive. -
- - {formatBytes(item?.size || 0)} - <Base> used, </Base> <Base>{formatBytes(trash?.size || 0)} in trash</Base> - -
-
- )} - {item?.id !== 'root' && item?.id !== 'trash' &&
} - -
- {folders.length > 0 && ( - <> - Folders - - {folders.map((item, index) => ( - { - return setParentId(item.id); - }} - checked={checked[item.id] || false} - onCheck={v => setChecked(_.pickBy({ ...checked, [item.id]: v }, _.identity))} - parentAccess={access} - /> - ))} -
- - )} - - Documents - - {documents.length === 0 && !loading && ( -
- Nothing here. - {!inTrash && canWrite && ( - <> - - Drag and drop files to upload them or click on the 'Add document' button. - -
- - - )} -
- )} - - {documents.map((item, index) => ( - {}} - item={item} - checked={checked[item.id] || false} - onCheck={v => setChecked(_.pickBy({ ...checked, [item.id]: v }, _.identity))} - parentAccess={access} - /> - ))} -
-
- + + ); }; + +const Drive = ({ + initialParentId, + context, +}: { + initialParentId?: string; + context?: EmbedContext; +}) => { + if (context?.tabId) { + return ; + } + + return ; +}; diff --git a/twake/frontend/src/app/views/applications/drive/item-row/document-row.tsx b/twake/frontend/src/app/views/applications/drive/item-row/document-row.tsx index 46668f8c1d..2bda63e20f 100644 --- a/twake/frontend/src/app/views/applications/drive/item-row/document-row.tsx +++ b/twake/frontend/src/app/views/applications/drive/item-row/document-row.tsx @@ -12,9 +12,12 @@ import { import { Base, BaseSmall } from 'app/atoms/text'; import Menu from 'app/components/menus/menu'; import { useDriveActions } from 'app/features/drive/hooks/use-drive-actions'; -import { useDrivePreview } from 'app/features/drive/hooks/use-drive-preview'; +import { usePublicLink } from 'app/features/drive/hooks/use-drive-item'; import { formatBytes } from 'app/features/drive/utils'; import fileUploadApiClient from 'app/features/files/api/file-upload-api-client'; +import { ToasterService } from 'app/features/global/services/toaster-service'; +import { copyToClipboard } from 'app/features/global/utils/CopyClipboard'; +import { useFileViewerModal } from 'app/features/viewer/hooks/use-viewer'; import { useState } from 'react'; import { useSetRecoilState } from 'recoil'; import Avatar from '../../../../atoms/avatar'; @@ -38,7 +41,8 @@ export const DocumentRow = ({ }: DriveItemProps) => { const [hover, setHover] = useState(false); const { download, update } = useDriveActions(); - const { open } = useDrivePreview(); + const { open } = useFileViewerModal(); + const publicLink = usePublicLink(item); const setVersionModal = useSetRecoilState(VersionsModalAtom); const setSelectorModalState = useSetRecoilState(SelectorModalAtom); @@ -74,7 +78,12 @@ export const DocumentRow = ({ else onClick(); }} > -
e.stopPropagation()}> +
{ + e.stopPropagation(); + preview(); + }} + > setAccessModalState({ open: true, id: item.id }), }, + { + type: 'menu', + text: 'Copy public link', + hide: !item.access_info.public?.level || item.access_info.public?.level === 'none', + onClick: () => { + copyToClipboard(publicLink); + ToasterService.success('Public link copied to clipboard'); + }, + }, { type: 'menu', text: 'Versions', @@ -171,7 +189,7 @@ export const DocumentRow = ({ }, }), }, - { type: 'separator' }, + { type: 'separator', hide: parentAccess === 'read' }, { type: 'menu', text: 'Move to trash', diff --git a/twake/frontend/src/app/views/applications/drive/item-row/folder-row.tsx b/twake/frontend/src/app/views/applications/drive/item-row/folder-row.tsx index 7d1d9a7e56..86f01c1694 100644 --- a/twake/frontend/src/app/views/applications/drive/item-row/folder-row.tsx +++ b/twake/frontend/src/app/views/applications/drive/item-row/folder-row.tsx @@ -4,7 +4,10 @@ import { Button } from 'app/atoms/button/button'; import { Base, BaseSmall } from 'app/atoms/text'; import Menu from 'app/components/menus/menu'; import { useDriveActions } from 'app/features/drive/hooks/use-drive-actions'; +import { usePublicLink } from 'app/features/drive/hooks/use-drive-item'; import { formatBytes } from 'app/features/drive/utils'; +import { ToasterService } from 'app/features/global/services/toaster-service'; +import { copyToClipboard } from 'app/features/global/utils/CopyClipboard'; import { useState } from 'react'; import { useSetRecoilState } from 'recoil'; import { PublicIcon } from '../components/public-icon'; @@ -31,6 +34,7 @@ export const FolderRow = ({ const setPropertiesModalState = useSetRecoilState(PropertiesModalAtom); const setConfirmDeleteModalState = useSetRecoilState(ConfirmDeleteModalAtom); const setConfirmTrashModalState = useSetRecoilState(ConfirmTrashModalAtom); + const publicLink = usePublicLink(item); return (
setAccessModalState({ open: true, id: item.id }), }, + { + type: 'menu', + text: 'Copy public link', + hide: !item.access_info.public?.level || item.access_info.public?.level === 'none', + onClick: () => { + copyToClipboard(publicLink); + ToasterService.success('Public link copied to clipboard'); + }, + }, { type: 'menu', text: 'Move', @@ -111,7 +124,7 @@ export const FolderRow = ({ }, }), }, - { type: 'separator' }, + { type: 'separator', hide: parentAccess === 'read' }, { type: 'menu', text: 'Move to trash', diff --git a/twake/frontend/src/app/views/applications/drive/modals/create/index.tsx b/twake/frontend/src/app/views/applications/drive/modals/create/index.tsx index 5aadcaee25..cf3cebdc97 100644 --- a/twake/frontend/src/app/views/applications/drive/modals/create/index.tsx +++ b/twake/frontend/src/app/views/applications/drive/modals/create/index.tsx @@ -1,9 +1,12 @@ import { Transition } from '@headlessui/react'; import { ChevronLeftIcon, DesktopComputerIcon } from '@heroicons/react/outline'; import { FolderIcon } from '@heroicons/react/solid'; +import Avatar from 'app/atoms/avatar'; import A from 'app/atoms/link'; import { Modal, ModalContent } from 'app/atoms/modal'; import { Base } from 'app/atoms/text'; +import { useApplications } from 'app/features/applications/hooks/use-applications'; +import { Application } from 'app/features/applications/types/application'; import { ReactNode } from 'react'; import { atom, useRecoilState } from 'recoil'; import { slideXTransition, slideXTransitionReverted } from 'src/utils/transitions'; @@ -23,8 +26,15 @@ export const CreateModalAtom = atom({ }, }); -export const CreateModal = ({ selectFromDevice }: { selectFromDevice: () => void }) => { +export const CreateModal = ({ + selectFromDevice, + addFromUrl, +}: { + selectFromDevice: () => void; + addFromUrl: (url: string, name: string) => void; +}) => { const [state, setState] = useRecoilState(CreateModalAtom); + const { applications } = useApplications(); return ( void onClick={() => selectFromDevice()} /> - {/* TODO get list of apps compatible with drive and show ability to create docs from them */} + {(applications || []) + .filter(app => app.display?.twake?.files?.editor?.empty_files?.length) + .reduce( + (a, app) => [ + ...a, + ...(app.display?.twake?.files?.editor?.empty_files || []) + .filter(ef => ef?.filename) + .map(ef => ({ + app, + emptyFile: ef, + })), + ], + [] as { + app: Application; + emptyFile: { + url: string; // "https://[...]/empty.docx"; + filename: string; // "Untitled.docx"; + name: string; // "Word Document" + }; + }[], + ) + .map((app, i) => { + return ( + + } + text={`${app.emptyFile.name} (${app.app.identity?.name})`} + onClick={() => addFromUrl(app.emptyFile.url, app.emptyFile.name)} + /> + ); + })}
diff --git a/twake/frontend/src/app/views/applications/drive/modals/update-access/internal-access.tsx b/twake/frontend/src/app/views/applications/drive/modals/update-access/internal-access.tsx index 323ae32404..bbbfe00de3 100644 --- a/twake/frontend/src/app/views/applications/drive/modals/update-access/internal-access.tsx +++ b/twake/frontend/src/app/views/applications/drive/modals/update-access/internal-access.tsx @@ -1,4 +1,5 @@ import Avatar from 'app/atoms/avatar'; +import { Checkbox } from 'app/atoms/input/input-checkbox'; import { Base, Info } from 'app/atoms/text'; import { useDriveItem } from 'app/features/drive/hooks/use-drive-item'; import { DriveFileAccessLevel } from 'app/features/drive/types'; @@ -29,7 +30,7 @@ export const InternalAccessManager = ({ id, disabled }: { id: string; disabled: General access management - Specific user or channel rule is applied first. Then least restrictive level will be chosen + User or channel specific rule is applied first. Then least restrictive level will be chosen between the parent folder and company accesses. @@ -37,25 +38,25 @@ export const InternalAccessManager = ({ id, disabled }: { id: string; disabled: {folderEntity && (
- Parent folder maximum level + Inherit parent folder
- Maximum level inherited from the parent folder. + Choose to inherit or not the parent folder permissions
- { + onChange={status => { update({ access_info: { entities: [ ...(item?.access_info.entities.filter(a => a.type !== 'folder') || []), - { ...folderEntity, level }, + { ...folderEntity, level: status ? 'manage' : 'none' }, ], public: item?.access_info.public, }, }); }} - level={folderEntity.level} + value={folderEntity.level === 'manage'} />
diff --git a/twake/frontend/src/app/views/applications/drive/modals/update-access/public-link-access.tsx b/twake/frontend/src/app/views/applications/drive/modals/update-access/public-link-access.tsx index c2cec3e063..dfc3490659 100644 --- a/twake/frontend/src/app/views/applications/drive/modals/update-access/public-link-access.tsx +++ b/twake/frontend/src/app/views/applications/drive/modals/update-access/public-link-access.tsx @@ -1,22 +1,13 @@ import A from 'app/atoms/link'; import { Base, Info } from 'app/atoms/text'; -import { useDriveItem } from 'app/features/drive/hooks/use-drive-item'; +import { useDriveItem, usePublicLink } from 'app/features/drive/hooks/use-drive-item'; import { ToasterService } from 'app/features/global/services/toaster-service'; import { copyToClipboard } from 'app/features/global/utils/CopyClipboard'; -import { useRef } from 'react'; -import short from 'short-uuid'; import { AccessLevel } from './common'; export const PublicLinkManager = ({ id, disabled }: { id: string; disabled?: boolean }) => { const { item, loading, update } = useDriveItem(id); - - const translator = useRef(short()).current; - const publicLink = - `${document.location.protocol}//${document.location.host}` + - `/shared/${translator.fromUUID(item?.company_id || '')}` + - `/drive/${translator.fromUUID(item?.id || '')}` + - `/t/${item?.access_info?.public?.token}`; - + const publicLink = usePublicLink(item); return ( <> Public link access @@ -45,7 +36,9 @@ export const PublicLinkManager = ({ id, disabled }: { id: string; disabled?: boo
{ diff --git a/twake/frontend/src/app/views/applications/drive/twake-tab-configuration.tsx b/twake/frontend/src/app/views/applications/drive/twake-tab-configuration.tsx new file mode 100644 index 0000000000..476201de0a --- /dev/null +++ b/twake/frontend/src/app/views/applications/drive/twake-tab-configuration.tsx @@ -0,0 +1,58 @@ +import { Modal } from 'app/atoms/modal'; +import { Info } from 'app/atoms/text'; +import Button from 'app/components/buttons/button'; +import { useDriveTwakeTab } from 'app/features/drive-twake/hooks/use-drive-twake-tab'; +import { useDriveItem } from 'app/features/drive/hooks/use-drive-item'; +import { useEffect, useState } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { EmbedContext } from '.'; +import Browser from './browser'; +import { SelectorModalAtom } from './modals/selector'; + +export default ({ context }: { context?: EmbedContext }) => { + const { tab, setTab, loading } = useDriveTwakeTab(context?.channelId || '', context?.tabId || ''); + const { item, loading: itemLoading } = useDriveItem(tab?.item_id || ''); + const [modifyConfiguration, setModifyConfiguration] = useState(false); + const setSelectorModalState = useSetRecoilState(SelectorModalAtom); + + if (loading || itemLoading) return <>; + + // If nothing is configured, then show the selector and when selected the folder will give access to the whole channel + const modalOpen = !tab || !item || modifyConfiguration; + const isConfigured = tab && item; + + useEffect(() => { + if (modalOpen) { + setSelectorModalState({ + open: true, + parent_id: 'root', + mode: 'move', + title: `Select what folder this tab should display`, + onSelected: async ids => { + await setTab(ids[0], 'write'); + }, + }); + } + }, [modalOpen]); + + // If configured then show the content of the tab and forward the fact that the access is done through a specific channel + return ( +
+ {isConfigured && ( + + )} + {!isConfigured && ( +
+ This Documents tabs is not configured yet. +
+ +
+ )} +
+ ); +}; diff --git a/twake/frontend/src/app/views/client/main-view/AppView/AppView.tsx b/twake/frontend/src/app/views/client/main-view/AppView/AppView.tsx index 2a1bea2af0..79447fcedb 100644 --- a/twake/frontend/src/app/views/client/main-view/AppView/AppView.tsx +++ b/twake/frontend/src/app/views/client/main-view/AppView/AppView.tsx @@ -29,7 +29,16 @@ const AppView: FC = props => { switch (app?.identity?.code) { case 'twake_drive': - return ; + return ( + + ); case 'twake_calendar': return ; case 'twake_tasks':