From 1880eb7704614c1088d2198de9dcf338294c8780 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 17 Apr 2024 14:53:01 +0530 Subject: [PATCH] [WEB-1019] chore: error state for unauthorized pages (#4219) * chore: private page access * chore: add error state for private pages --------- Co-authored-by: NarayanBavisetti --- apiserver/plane/app/views/page/base.py | 12 +- .../pages/dropdowns/quick-actions.tsx | 157 ++++++++---------- .../pages/editor/header/options-dropdown.tsx | 43 ++--- .../projects/[projectId]/pages/[pageId].tsx | 50 ++++-- web/store/pages/page.store.ts | 134 +++++++++------ web/store/pages/project-page.store.ts | 12 +- yarn.lock | 2 +- 7 files changed, 226 insertions(+), 184 deletions(-) diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 1bbfdf33f..4076a53b1 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -169,9 +169,15 @@ class PageViewSet(BaseViewSet): def retrieve(self, request, slug, project_id, pk=None): page = self.get_queryset().filter(pk=pk).first() - return Response( - PageDetailSerializer(page).data, status=status.HTTP_200_OK - ) + if page is None: + return Response( + {"error": "Page not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + else: + return Response( + PageDetailSerializer(page).data, status=status.HTTP_200_OK + ) def lock(self, request, slug, project_id, pk): page = Page.objects.filter( diff --git a/web/components/pages/dropdowns/quick-actions.tsx b/web/components/pages/dropdowns/quick-actions.tsx index 241b08ae9..f88aa9757 100644 --- a/web/components/pages/dropdowns/quick-actions.tsx +++ b/web/components/pages/dropdowns/quick-actions.tsx @@ -20,7 +20,17 @@ export const PageQuickActions: React.FC = observer((props) => { // states const [deletePageModal, setDeletePageModal] = useState(false); // store hooks - const { access, archive, archived_at, makePublic, makePrivate, restore } = usePage(pageId); + const { + access, + archive, + archived_at, + makePublic, + makePrivate, + restore, + canCurrentUserArchivePage, + canCurrentUserChangeAccess, + canCurrentUserDeletePage, + } = usePage(pageId); const pageLink = `${workspaceSlug}/projects/${projectId}/pages/${pageId}`; const handleCopyText = () => @@ -31,8 +41,53 @@ export const PageQuickActions: React.FC = observer((props) => { message: "Page link copied to clipboard.", }); }); + const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank"); + const MENU_ITEMS: { + key: string; + action: () => void; + label: string; + icon: React.FC; + shouldRender: boolean; + }[] = [ + { + key: "copy-link", + action: handleCopyText, + label: "Copy link", + icon: Link, + shouldRender: true, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + label: "Open in new tab", + icon: ExternalLink, + shouldRender: true, + }, + { + key: "archive-restore", + action: archived_at ? restore : archive, + label: archived_at ? "Restore" : "Archive", + icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, + shouldRender: canCurrentUserArchivePage, + }, + { + key: "make-public-private", + action: access === 0 ? makePrivate : makePublic, + label: access === 0 ? "Make private" : "Make public", + icon: access === 0 ? Lock : UsersRound, + shouldRender: canCurrentUserChangeAccess && !archived_at, + }, + { + key: "delete", + action: () => setDeletePageModal(true), + label: "Delete", + icon: Trash2, + shouldRender: canCurrentUserDeletePage && !!archived_at, + }, + ]; + return ( <> = observer((props) => { projectId={projectId} /> - { - e.preventDefault(); - e.stopPropagation(); - handleCopyText(); - }} - > - - - Copy link - - - { - e.preventDefault(); - e.stopPropagation(); - handleOpenInNewTab(); - }} - > - - - Open in new tab - - - { - e.preventDefault(); - e.stopPropagation(); - if (archived_at) restore(); - else archive(); - }} - > - - {archived_at ? ( - <> - - Restore - - ) : ( - <> - - Archive - - )} - - - {!archived_at && ( - { - e.preventDefault(); - e.stopPropagation(); - access === 0 ? makePrivate() : makePublic(); - }} - > - - {access === 0 ? ( - <> - - Make private - - ) : ( - <> - - Make public - - )} - - - )} - {archived_at && ( - { - e.preventDefault(); - e.stopPropagation(); - setDeletePageModal(true); - }} - > - - - Delete - - - )} + {MENU_ITEMS.map((item) => { + if (!item.shouldRender) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className="flex items-center gap-2" + > + + {item.label} + + ); + })} ); diff --git a/web/components/pages/editor/header/options-dropdown.tsx b/web/components/pages/editor/header/options-dropdown.tsx index 53bfc43b5..d2691175a 100644 --- a/web/components/pages/editor/header/options-dropdown.tsx +++ b/web/components/pages/editor/header/options-dropdown.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import { Clipboard, Copy, Link, Lock } from "lucide-react"; +import { ArchiveRestoreIcon, Clipboard, Copy, Link, Lock, LockOpen } from "lucide-react"; // document editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/document-editor"; // ui @@ -21,6 +21,9 @@ export const PageOptionsDropdown: React.FC = observer((props) => { const { editorRef, handleDuplicatePage, pageStore } = props; // store values const { + archived_at, + is_locked, + id, archive, lock, unlock, @@ -99,7 +102,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { { key: "copy-page-link", action: () => { - copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${pageStore.id}`).then(() => + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${id}`).then(() => setToast({ type: TOAST_TYPE.SUCCESS, title: "Successful!", @@ -119,32 +122,18 @@ export const PageOptionsDropdown: React.FC = observer((props) => { shouldRender: canCurrentUserDuplicatePage, }, { - key: "lock-page", - action: handleLockPage, - label: "Lock page", - icon: Lock, - shouldRender: !pageStore.is_locked && canCurrentUserLockPage, + key: "lock-unlock-page", + action: is_locked ? handleUnlockPage : handleLockPage, + label: is_locked ? "Unlock page" : "Lock page", + icon: is_locked ? LockOpen : Lock, + shouldRender: canCurrentUserLockPage, }, { - key: "unlock-page", - action: handleUnlockPage, - label: "Unlock page", - icon: Lock, - shouldRender: pageStore.is_locked && canCurrentUserLockPage, - }, - { - key: "archive-page", - action: handleArchivePage, - label: "Archive page", - icon: ArchiveIcon, - shouldRender: !pageStore.archived_at && canCurrentUserArchivePage, - }, - { - key: "restore-page", - action: handleRestorePage, - label: "Restore page", - icon: ArchiveIcon, - shouldRender: !!pageStore.archived_at && canCurrentUserArchivePage, + key: "archive-restore-page", + action: archived_at ? handleRestorePage : handleArchivePage, + label: archived_at ? "Restore page" : "Archive page", + icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon, + shouldRender: canCurrentUserArchivePage, }, ]; @@ -166,7 +155,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { return ( -
{item.label}
+ {item.label}
); })} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index 59016d05f..dc244fb69 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -1,5 +1,6 @@ import { ReactElement, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import useSWR from "swr"; @@ -8,12 +9,14 @@ import { EditorRefApi, useEditorMarkings } from "@plane/document-editor"; // types import { TPage } from "@plane/types"; // ui -import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; +import { Spinner, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; // components import { PageHead } from "@/components/core"; import { PageDetailsHeader } from "@/components/headers"; import { IssuePeekOverview } from "@/components/issues"; import { PageEditorBody, PageEditorHeaderRoot } from "@/components/pages"; +// helpers +import { cn } from "@/helpers/common.helper"; // hooks import { usePage, useProjectPages } from "@/hooks/store"; // layouts @@ -46,15 +49,16 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { }); // fetching page details - const { data: swrPageDetails, isValidating } = useSWR( - pageId ? `PAGE_DETAILS_${pageId}` : null, - pageId ? () => getPageById(pageId.toString()) : null, - { - revalidateIfStale: false, - revalidateOnFocus: true, - revalidateOnReconnect: true, - } - ); + const { + data: swrPageDetails, + isValidating, + error: pageDetailsError, + } = useSWR(pageId ? `PAGE_DETAILS_${pageId}` : null, pageId ? () => getPageById(pageId.toString()) : null, { + revalidateIfStale: false, + revalidateOnFocus: true, + revalidateOnReconnect: true, + }); + useEffect( () => () => { if (pageStore.cleanup) pageStore.cleanup(); @@ -62,17 +66,29 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { [pageStore] ); - const handleEditorReady = (value: boolean) => setEditorReady(value); - - const handleReadOnlyEditorReady = () => setReadOnlyEditorReady(true); - - if (!pageStore || !pageStore.id) + if ((!pageStore || !pageStore.id) && !pageDetailsError) return (
); + if (pageDetailsError) + return ( +
+

Page not found

+

+ The page you are trying to access doesn{"'"}t exist or you don{"'"}t have permission to view it. +

+ + View other Pages + +
+ ); + // we need to get the values of title and description from the page store but we don't have to subscribe to those values const pageTitle = pageStore?.name; @@ -132,9 +148,9 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { swrPageDetails={swrPageDetails} control={control} editorRef={editorRef} - handleEditorReady={handleEditorReady} + handleEditorReady={(val) => setEditorReady(val)} readOnlyEditorRef={readOnlyEditorRef} - handleReadOnlyEditorReady={handleReadOnlyEditorReady} + handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)} handleSubmit={() => handleSubmit(handleUpdatePage)()} markings={markings} pageStore={pageStore} diff --git a/web/store/pages/page.store.ts b/web/store/pages/page.store.ts index 81d821e7e..c901e4e87 100644 --- a/web/store/pages/page.store.ts +++ b/web/store/pages/page.store.ts @@ -21,6 +21,7 @@ export interface IPageStore extends TPage { canCurrentUserEditPage: boolean; // it will give the user permission to read the page or write the page canCurrentUserDuplicatePage: boolean; canCurrentUserLockPage: boolean; + canCurrentUserChangeAccess: boolean; canCurrentUserArchivePage: boolean; canCurrentUserDeletePage: boolean; isContentEditable: boolean; @@ -72,7 +73,10 @@ export class PageStore implements IPageStore { // service pageService: PageService; - constructor(private store: RootStore, page: TPage) { + constructor( + private store: RootStore, + page: TPage + ) { this.id = page?.id || undefined; this.name = page?.name || undefined; this.description_html = page?.description_html || undefined; @@ -122,6 +126,7 @@ export class PageStore implements IPageStore { canCurrentUserEditPage: computed, canCurrentUserDuplicatePage: computed, canCurrentUserLockPage: computed, + canCurrentUserChangeAccess: computed, canCurrentUserArchivePage: computed, canCurrentUserDeletePage: computed, isContentEditable: computed, @@ -245,12 +250,19 @@ export class PageStore implements IPageStore { return this.isCurrentUserOwner || (!!currentUserProjectRole && currentUserProjectRole >= EUserProjectRoles.MEMBER); } + /** + * @description returns true if the current logged in user can change the access of the page + */ + get canCurrentUserChangeAccess() { + return this.isCurrentUserOwner; + } + /** * @description returns true if the current logged in user can archive the page */ get canCurrentUserArchivePage() { const currentUserProjectRole = this.store.user.membership.currentProjectRole; - return this.isCurrentUserOwner || (!!currentUserProjectRole && currentUserProjectRole >= EUserProjectRoles.MEMBER); + return this.isCurrentUserOwner || currentUserProjectRole === EUserProjectRoles.ADMIN; } /** @@ -315,13 +327,14 @@ export class PageStore implements IPageStore { set(this, key, currentPageResponse?.[currentPageKey] || undefined); }); }); - } catch { + } catch (error) { runInAction(() => { Object.keys(pageData).forEach((key) => { const currentPageKey = key as keyof TPage; set(this, key, currentPage?.[currentPageKey] || undefined); }); }); + throw error; } }; @@ -349,10 +362,11 @@ export class PageStore implements IPageStore { ...updatedProps, }, }); - } catch { + } catch (error) { runInAction(() => { this.view_props = currentViewProps; }); + throw error; } }; @@ -366,13 +380,16 @@ export class PageStore implements IPageStore { const pageAccess = this.access; runInAction(() => (this.access = EPageAccess.PUBLIC)); - await this.pageService - .update(workspaceSlug, projectId, this.id, { + try { + await this.pageService.update(workspaceSlug, projectId, this.id, { access: EPageAccess.PUBLIC, - }) - .catch(() => { - runInAction(() => (this.access = pageAccess)); }); + } catch (error) { + runInAction(() => { + this.access = pageAccess; + }); + throw error; + } }; /** @@ -385,13 +402,16 @@ export class PageStore implements IPageStore { const pageAccess = this.access; runInAction(() => (this.access = EPageAccess.PRIVATE)); - await this.pageService - .update(workspaceSlug, projectId, this.id, { + try { + await this.pageService.update(workspaceSlug, projectId, this.id, { access: EPageAccess.PRIVATE, - }) - .catch(() => { - runInAction(() => (this.access = pageAccess)); }); + } catch (error) { + runInAction(() => { + this.access = pageAccess; + }); + throw error; + } }; /** @@ -404,36 +424,11 @@ export class PageStore implements IPageStore { const pageIsLocked = this.is_locked; runInAction(() => (this.is_locked = true)); - await this.pageService.lock(workspaceSlug, projectId, this.id).catch(() => { - runInAction(() => (this.is_locked = pageIsLocked)); - }); - }; - - /** - * @description archive the page - */ - archive = async () => { - const { workspaceSlug, projectId } = this.store.app.router; - if (!workspaceSlug || !projectId || !this.id) return undefined; - - await this.pageService.archive(workspaceSlug, projectId, this.id).then((res) => { + await this.pageService.lock(workspaceSlug, projectId, this.id).catch((error) => { runInAction(() => { - this.archived_at = res.archived_at; - }); - }); - }; - - /** - * @description restore the page - */ - restore = async () => { - const { workspaceSlug, projectId } = this.store.app.router; - if (!workspaceSlug || !projectId || !this.id) return undefined; - - await this.pageService.restore(workspaceSlug, projectId, this.id).then(() => { - runInAction(() => { - this.archived_at = null; + this.is_locked = pageIsLocked; }); + throw error; }); }; @@ -447,11 +442,48 @@ export class PageStore implements IPageStore { const pageIsLocked = this.is_locked; runInAction(() => (this.is_locked = false)); - await this.pageService.unlock(workspaceSlug, projectId, this.id).catch(() => { - runInAction(() => (this.is_locked = pageIsLocked)); + await this.pageService.unlock(workspaceSlug, projectId, this.id).catch((error) => { + runInAction(() => { + this.is_locked = pageIsLocked; + }); + throw error; }); }; + /** + * @description archive the page + */ + archive = async () => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.id) return undefined; + + try { + const response = await this.pageService.archive(workspaceSlug, projectId, this.id); + runInAction(() => { + this.archived_at = response.archived_at; + }); + } catch (error) { + throw error; + } + }; + + /** + * @description restore the page + */ + restore = async () => { + const { workspaceSlug, projectId } = this.store.app.router; + if (!workspaceSlug || !projectId || !this.id) return undefined; + + try { + await this.pageService.restore(workspaceSlug, projectId, this.id); + runInAction(() => { + this.archived_at = null; + }); + } catch (error) { + throw error; + } + }; + /** * @description add the page to favorites */ @@ -464,8 +496,11 @@ export class PageStore implements IPageStore { this.is_favorite = true; }); - await this.pageService.addToFavorites(workspaceSlug, projectId, this.id).catch(() => { - runInAction(() => (this.is_favorite = pageIsFavorite)); + await this.pageService.addToFavorites(workspaceSlug, projectId, this.id).catch((error) => { + runInAction(() => { + this.is_favorite = pageIsFavorite; + }); + throw error; }); }; @@ -481,8 +516,11 @@ export class PageStore implements IPageStore { this.is_favorite = false; }); - await this.pageService.removeFromFavorites(workspaceSlug, projectId, this.id).catch(() => { - runInAction(() => (this.is_favorite = pageIsFavorite)); + await this.pageService.removeFromFavorites(workspaceSlug, projectId, this.id).catch((error) => { + runInAction(() => { + this.is_favorite = pageIsFavorite; + }); + throw error; }); }; } diff --git a/web/store/pages/project-page.store.ts b/web/store/pages/project-page.store.ts index 136ce61a5..a9572e7d8 100644 --- a/web/store/pages/project-page.store.ts +++ b/web/store/pages/project-page.store.ts @@ -148,7 +148,7 @@ export class ProjectPageStore implements IProjectPageStore { }); return pages; - } catch { + } catch (error) { runInAction(() => { this.loader = undefined; this.error = { @@ -156,6 +156,7 @@ export class ProjectPageStore implements IProjectPageStore { description: "Failed to fetch the pages, Please try again later.", }; }); + throw error; } }; @@ -181,7 +182,7 @@ export class ProjectPageStore implements IProjectPageStore { }); return page; - } catch { + } catch (error) { runInAction(() => { this.loader = undefined; this.error = { @@ -189,6 +190,7 @@ export class ProjectPageStore implements IProjectPageStore { description: "Failed to fetch the page, Please try again later.", }; }); + throw error; } }; @@ -213,7 +215,7 @@ export class ProjectPageStore implements IProjectPageStore { }); return page; - } catch { + } catch (error) { runInAction(() => { this.loader = undefined; this.error = { @@ -221,6 +223,7 @@ export class ProjectPageStore implements IProjectPageStore { description: "Failed to create a page, Please try again later.", }; }); + throw error; } }; @@ -235,7 +238,7 @@ export class ProjectPageStore implements IProjectPageStore { await this.service.remove(workspaceSlug, projectId, pageId); runInAction(() => unset(this.data, [pageId])); - } catch { + } catch (error) { runInAction(() => { this.loader = undefined; this.error = { @@ -243,6 +246,7 @@ export class ProjectPageStore implements IProjectPageStore { description: "Failed to delete a page, Please try again later.", }; }); + throw error; } }; } diff --git a/yarn.lock b/yarn.lock index 1fb78bb2c..9a3d22cf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2747,7 +2747,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^18.2.42": +"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42": version "18.2.42" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7" integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==