From 2c66634c89767cccf79d8cbdcea0b441c202c325 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Sun, 15 Sep 2024 13:45:13 +0800 Subject: [PATCH] feat: support invitation page (#6300) --- frontend/appflowy_web_app/deploy/server.ts | 2 +- .../services/js-services/http/http_api.ts | 59 +++++- .../application/services/js-services/index.ts | 9 +- .../src/application/services/services.type.ts | 4 +- .../services/tauri-services/index.ts | 10 +- .../src/application/session/sign_in.ts | 12 +- .../appflowy_web_app/src/application/types.ts | 12 ++ .../components/_shared/modal/NormalModal.tsx | 12 +- .../src/components/app/App.tsx | 12 +- .../src/components/app/AppConfig.tsx | 2 +- .../src/components/login/LinearBuffer.tsx | 43 ++++ .../src/components/login/LoginAuth.tsx | 62 +++++- .../publish/header/PublishViewHeader.tsx | 5 +- .../src/components/publish/header/utils.ts | 12 -- .../src/pages/AcceptInvitationPage.tsx | 199 ++++++++++++++++++ .../appflowy_web_app/src/utils/open_schema.ts | 14 +- frontend/resources/translations/en.json | 27 ++- 17 files changed, 446 insertions(+), 50 deletions(-) create mode 100644 frontend/appflowy_web_app/src/components/login/LinearBuffer.tsx create mode 100644 frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx diff --git a/frontend/appflowy_web_app/deploy/server.ts b/frontend/appflowy_web_app/deploy/server.ts index 221116aa3a..39a939e27b 100644 --- a/frontend/appflowy_web_app/deploy/server.ts +++ b/frontend/appflowy_web_app/deploy/server.ts @@ -68,7 +68,7 @@ const createServer = async (req: Request) => { logger.info(`Request URL: ${hostname}${reqUrl.pathname}`); - if (['/after-payment', '/login', '/as-template'].includes(reqUrl.pathname)) { + if (['/after-payment', '/login', '/as-template', '/accept-invitation'].includes(reqUrl.pathname)) { timer(); const htmlData = fs.readFileSync(indexPath, 'utf8'); const $ = load(htmlData); diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts index cfeda5e915..670c0423d9 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts @@ -11,7 +11,7 @@ import { TemplateCreator, TemplateCreatorFormValues, TemplateSummary, UploadTemplatePayload, } from '@/application/template.type'; -import { FolderView, User, View, Workspace } from '@/application/types'; +import { FolderView, Invitation, User, View, Workspace } from '@/application/types'; import axios, { AxiosInstance } from 'axios'; import dayjs from 'dayjs'; @@ -97,13 +97,33 @@ export async function signInWithUrl (url: string) { } const params = new URLSearchParams(hash.slice(1)); + const accessToken = params.get('access_token'); const refresh_token = params.get('refresh_token'); - if (!refresh_token) { - return Promise.reject('No access_token found'); + if (!accessToken || !refresh_token) { + return Promise.reject({ + code: -1, + message: 'No access token or refresh token found', + }); } - await refreshToken(refresh_token); + try { + await verifyToken(accessToken); + } catch (e) { + return Promise.reject({ + code: -1, + message: 'Verify token failed', + }); + } + + try { + await refreshToken(refresh_token); + } catch (e) { + return Promise.reject({ + code: -1, + message: 'Refresh token failed', + }); + } } export async function verifyToken (accessToken: string) { @@ -753,4 +773,35 @@ export async function uploadFileToCDN (file: File) { } return Promise.reject(data); +} + +export async function getInvitation (invitationId: string) { + const url = `/api/workspace/invite/${invitationId}`; + const response = await axiosInstance?.get<{ + code: number; + data?: Invitation; + message: string; + }>(url); + + const data = response?.data; + + if (data?.code === 0 && data.data) { + return data.data; + } + + return Promise.reject(data); +} + +export async function acceptInvitation (invitationId: string) { + const url = `/api/workspace/accept-invite/${invitationId}`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data.message); } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 268c5170ad..8b1fc0616a 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -170,7 +170,6 @@ export class AFClientService implements AFService { async loginAuth (url: string) { try { - console.log('loginAuth', url); await APIService.signInWithUrl(url); emit(EventType.SESSION_VALID); afterAuth(); @@ -315,4 +314,12 @@ export class AFClientService implements AFService { return APIService.uploadFileToCDN(file); } + async getInvitation (invitationId: string) { + return APIService.getInvitation(invitationId); + } + + async acceptInvitation (invitationId: string) { + return APIService.acceptInvitation(invitationId); + } + } diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index 435153e2c5..11eac0d903 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -9,7 +9,7 @@ import { UploadTemplatePayload, } from '@/application/template.type'; import * as Y from 'yjs'; -import { DuplicatePublishView, FolderView, User, View, Workspace } from '@/application/types'; +import { DuplicatePublishView, FolderView, Invitation, User, View, Workspace } from '@/application/types'; export type AFService = PublishService; @@ -75,4 +75,6 @@ export interface PublishService { updateTemplateCategory: (categoryId: string, category: TemplateCategoryFormValues) => Promise; updateTemplateCreator: (creatorId: string, creator: TemplateCreatorFormValues) => Promise; uploadFileToCDN: (file: File) => Promise; + getInvitation: (invitationId: string) => Promise; + acceptInvitation: (invitationId: string) => Promise; } diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts index 8ac0fd8844..153293ca26 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts @@ -9,7 +9,7 @@ import { } from '@/application/template.type'; import { nanoid } from 'nanoid'; import { YMap } from 'yjs/dist/src/types/YMap'; -import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types'; +import { DuplicatePublishView, FolderView, Invitation, User, Workspace } from '@/application/types'; export class AFClientService implements AFService { private deviceId: string = nanoid(8); @@ -165,4 +165,12 @@ export class AFClientService implements AFService { uploadFileToCDN (_file: File): Promise { return Promise.resolve(''); } + + acceptInvitation (_invitationId: string): Promise { + return Promise.reject('Method not implemented'); + } + + getInvitation (_invitationId: string): Promise { + return Promise.reject('Method not implemented'); + } } diff --git a/frontend/appflowy_web_app/src/application/session/sign_in.ts b/frontend/appflowy_web_app/src/application/session/sign_in.ts index 49f817788b..9efd523bf3 100644 --- a/frontend/appflowy_web_app/src/application/session/sign_in.ts +++ b/frontend/appflowy_web_app/src/application/session/sign_in.ts @@ -1,24 +1,24 @@ -export function saveRedirectTo(redirectTo: string) { +export function saveRedirectTo (redirectTo: string) { localStorage.setItem('redirectTo', redirectTo); } -export function getRedirectTo() { +export function getRedirectTo () { return localStorage.getItem('redirectTo'); } -export function clearRedirectTo() { +export function clearRedirectTo () { localStorage.removeItem('redirectTo'); } export const AUTH_CALLBACK_PATH = '/auth/callback'; export const AUTH_CALLBACK_URL = `${window.location.origin}${AUTH_CALLBACK_PATH}`; -export function withSignIn() { +export function withSignIn () { return function ( // eslint-disable-next-line _target: any, _propertyKey: string, - descriptor: PropertyDescriptor + descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; @@ -40,7 +40,7 @@ export function withSignIn() { }; } -export function afterAuth() { +export function afterAuth () { const redirectTo = getRedirectTo(); if (redirectTo) { diff --git a/frontend/appflowy_web_app/src/application/types.ts b/frontend/appflowy_web_app/src/application/types.ts index 48a44dd293..50d0e17931 100644 --- a/frontend/appflowy_web_app/src/application/types.ts +++ b/frontend/appflowy_web_app/src/application/types.ts @@ -65,4 +65,16 @@ export interface View { extra: ViewExtra | null; children: View[]; is_published: boolean; +} + +export interface Invitation { + invite_id: string; + workspace_id: string; + workspace_name: string; + inviter_email: string; + inviter_name: string; + inviter_icon: string; + workspace_icon: string; + member_count: number; + status: 'Accepted' | 'Pending'; } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx b/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx index 8b95d6eb89..858b6e3341 100644 --- a/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx @@ -16,6 +16,7 @@ export interface NormalModalProps extends DialogProps { okButtonProps?: ButtonProps; cancelButtonProps?: ButtonProps; okLoading?: boolean; + closable?: boolean; } export function NormalModal ({ @@ -30,6 +31,7 @@ export function NormalModal ({ okButtonProps, cancelButtonProps, okLoading, + closable = true, ...dialogProps }: NormalModalProps) { const { t } = useTranslation(); @@ -39,7 +41,7 @@ export function NormalModal ({ return ( { - if (e.key === 'Escape') { + if (e.key === 'Escape' && closable) { onClose?.(); } }} @@ -48,16 +50,18 @@ export function NormalModal ({
{title}
-
+ {closable &&
-
+
} +
{children}
- diff --git a/frontend/appflowy_web_app/src/components/publish/header/utils.ts b/frontend/appflowy_web_app/src/components/publish/header/utils.ts index 7d847357d1..8cb154090e 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/utils.ts +++ b/frontend/appflowy_web_app/src/components/publish/header/utils.ts @@ -1,16 +1,4 @@ import { View } from '@/application/types'; -import { getOS, openAppOrDownload } from '@/utils/open_schema'; -import { iosDownloadLink, androidDownloadLink, desktopDownloadLink, openAppFlowySchema } from '@/utils/url'; - -export function openOrDownload () { - const os = getOS(); - const downloadUrl = os === 'ios' ? iosDownloadLink : os === 'android' ? androidDownloadLink : desktopDownloadLink; - - return openAppOrDownload({ - appScheme: openAppFlowySchema, - downloadUrl, - }); -} export function findAncestors (data: View[], targetId: string, currentPath: View[] = []): View[] | null { for (const item of data) { diff --git a/frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx b/frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx new file mode 100644 index 0000000000..bae2773879 --- /dev/null +++ b/frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx @@ -0,0 +1,199 @@ +import { Invitation } from '@/application/types'; +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { AFConfigContext, useCurrentUser, useService } from '@/components/app/app.hooks'; +import { stringAvatar } from '@/utils/color'; +import { isFlagEmoji } from '@/utils/emoji'; +import { openOrDownload } from '@/utils/open_schema'; +import { openAppFlowySchema } from '@/utils/url'; +import { EmailOutlined } from '@mui/icons-material'; +import { Avatar, Button, Divider } from '@mui/material'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg'; +import { ReactComponent as ErrorIcon } from '@/assets/error.svg'; + +function AcceptInvitationPage () { + const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated; + const currentUser = useCurrentUser(); + const navigate = useNavigate(); + const openLoginModal = useContext(AFConfigContext)?.openLoginModal; + const [searchParams] = useSearchParams(); + const invitationId = searchParams.get('invited_id'); + const service = useService(); + const [invitation, setInvitation] = useState(); + const [modalOpened, setModalOpened] = useState(false); + const { t } = useTranslation(); + + useEffect(() => { + if (!isAuthenticated) { + navigate('/login?redirectTo=' + encodeURIComponent(window.location.href)); + } + }, [isAuthenticated, navigate]); + + const loadInvitation = useCallback(async (invitationId: string) => { + if (!service) return; + try { + const res = await service.getInvitation(invitationId); + + if (res.status === 'Accepted') { + notify.warning(t('invitation.alreadyAccepted')); + } + + setInvitation(res); + // eslint-disable-next-line + } catch (e: any) { + setModalOpened(true); + } + }, [service, t]); + + useEffect(() => { + if (!invitationId) return; + void loadInvitation(invitationId); + }, [loadInvitation, invitationId]); + + const workspaceIconProps = useMemo(() => { + if (!invitation) return {}; + + return getAvatar({ + icon: invitation.workspace_icon, + name: invitation.workspace_name, + }); + }, [invitation]); + + const inviterIconProps = useMemo(() => { + if (!invitation) return {}; + + return getAvatar({ + icon: invitation.inviter_icon, + name: invitation.inviter_name, + }); + }, [invitation]); + + return ( +
+
+ +
+
+ +
+ {t('invitation.join')} + {' '} + {currentUser?.name} + {' '} + {t('invitation.on')} + {' '} + {invitation?.workspace_name} + +
+ +
+ +
+
{t('invitation.invitedBy')}
+
{invitation?.inviter_name}
+
{t('invitation.membersCount', { + count: invitation?.member_count || 0, + })}
+
+
+
+ {t('invitation.tip')} +
+
+ + {currentUser?.email} +
+ + +
+ { + setModalOpened(false); + navigate('/'); + }} + closable={false} + cancelText={t('invitation.errorModal.close')} + onOk={openLoginModal} + okText={t('invitation.errorModal.changeAccount')} + title={
+ + {t('invitation.errorModal.title')} +
} + open={modalOpened} + > +
+ {t('invitation.errorModal.description', { + email: currentUser?.email, + })} +
+
+
+ ); +} + +function getAvatar (item: { + icon?: string; + name: string; +}) { + if (item.icon) { + const isFlag = isFlagEmoji(item.icon); + + return { + children: {item.icon}, + sx: { + bgcolor: 'var(--bg-body)', + color: 'var(--text-title)', + }, + }; + } + + return stringAvatar(item.name || ''); +} + +export default AcceptInvitationPage; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/utils/open_schema.ts b/frontend/appflowy_web_app/src/utils/open_schema.ts index f4e4016ed7..69a7ce6c4d 100644 --- a/frontend/appflowy_web_app/src/utils/open_schema.ts +++ b/frontend/appflowy_web_app/src/utils/open_schema.ts @@ -1,3 +1,5 @@ +import { androidDownloadLink, desktopDownloadLink, iosDownloadLink, openAppFlowySchema } from '@/utils/url'; + type OS = 'ios' | 'android' | 'other'; interface AppConfig { @@ -89,4 +91,14 @@ export const openAppOrDownload = (config: AppConfig): void => { removeIframe(iframe); redirectToUrl(downloadUrl); }; -}; \ No newline at end of file +}; + +export function openOrDownload (schema?: string) { + const os = getOS(); + const downloadUrl = os === 'ios' ? iosDownloadLink : os === 'android' ? androidDownloadLink : desktopDownloadLink; + + return openAppOrDownload({ + appScheme: schema || openAppFlowySchema, + downloadUrl, + }); +} \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 686ab8b60a..e29cc2c7be 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -380,7 +380,8 @@ "next": "Next", "previous": "Previous", "submit": "Submit", - "download": "Download" + "download": "Download", + "backToHome": "Back to Home" }, "label": { "welcome": "Welcome!", @@ -2559,5 +2560,29 @@ "resetZoom": "Reset zoom", "zoomIn": "Zoom in", "zoomOut": "Zoom out" + }, + "invitation": { + "join": "Join", + "on": "on", + "invitedBy": "Invited by", + "membersCount": { + "zero": "{count} members", + "one": "{count} member", + "many": "{count} members", + "other": "{count} members" + }, + "tip": "You’ve been invited to Join this workspace with the contact information below. If this is incorrect, contact your administrator to resend the invite.", + "joinWorkspace": "Join workspace", + "success": "You've successfully joined the workspace", + "successMessage": "You can now access all the pages and workspaces within it.", + "openWorkspace": "Open AppFlowy", + "alreadyAccepted": "You've already accepted the invitation", + "errorModal": { + "title": "Something went wrong", + "description": "Your current account {email} may not have access to this workspace. Please log in with the correct account or contact the workspace owner for help.", + "contactOwner": "Contact owner", + "close": "Back to home", + "changeAccount": "Change account" + } } } \ No newline at end of file