feat: support invitation page (#6300)

This commit is contained in:
Kilu.He 2024-09-15 13:45:13 +08:00 committed by GitHub
parent 609ea27c42
commit 2c66634c89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 446 additions and 50 deletions

View File

@ -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);

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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<void>;
updateTemplateCreator: (creatorId: string, creator: TemplateCreatorFormValues) => Promise<void>;
uploadFileToCDN: (file: File) => Promise<string>;
getInvitation: (invitationId: string) => Promise<Invitation>;
acceptInvitation: (invitationId: string) => Promise<void>;
}

View File

@ -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<string> {
return Promise.resolve('');
}
acceptInvitation (_invitationId: string): Promise<void> {
return Promise.reject('Method not implemented');
}
getInvitation (_invitationId: string): Promise<Invitation> {
return Promise.reject('Method not implemented');
}
}

View File

@ -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) {

View File

@ -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';
}

View File

@ -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 (
<Dialog
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' && closable) {
onClose?.();
}
}}
@ -48,16 +50,18 @@ export function NormalModal ({
<div className={'relative flex flex-col gap-4 p-5'}>
<div className={'flex w-full items-center justify-between text-base font-medium'}>
<div className={'flex-1 text-center '}>{title}</div>
<div className={'relative -right-1.5'}>
{closable && <div className={'relative -right-1.5'}>
<IconButton size={'small'} color={'inherit'} className={'h-6 w-6'} onClick={onClose || onCancel}>
<CloseIcon className={'h-4 w-4'} />
</IconButton>
</div>
</div>}
</div>
<div className={'flex-1'}>{children}</div>
<div className={'flex w-full justify-end gap-3'}>
<Button color={'inherit'} variant={'outlined'} onClick={() => {
<Button
color={'inherit'} variant={'outlined'} onClick={() => {
if (onCancel) {
onCancel();
} else {

View File

@ -1,6 +1,7 @@
import { AUTH_CALLBACK_PATH } from '@/application/session/sign_in';
import NotFound from '@/components/error/NotFound';
import LoginAuth from '@/components/login/LoginAuth';
import AcceptInvitationPage from '@/pages/AcceptInvitationPage';
import AfterPaymentPage from '@/pages/AfterPaymentPage';
import AsTemplatePage from '@/pages/AsTemplatePage';
import LoginPage from '@/pages/LoginPage';
@ -15,15 +16,16 @@ const AppMain = withAppWrapper(() => {
<Route path={'/:namespace/:publishName'} element={<PublishPage />} />
<Route path={'/login'} element={<LoginPage />} />
<Route path={AUTH_CALLBACK_PATH} element={<LoginAuth />} />
<Route path='/404' element={<NotFound />} />
<Route path='/after-payment' element={<AfterPaymentPage />} />
<Route path='/as-template' element={<AsTemplatePage />} />
<Route path='*' element={<NotFound />} />
<Route path="/404" element={<NotFound />} />
<Route path="/after-payment" element={<AfterPaymentPage />} />
<Route path="/as-template" element={<AsTemplatePage />} />
<Route path="/accept-invitation" element={<AcceptInvitationPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
});
function App() {
function App () {
return (
<BrowserRouter>
<AppMain />

View File

@ -22,7 +22,7 @@ function AppConfig ({ children }: { children: React.ReactNode }) {
const openLoginModal = useCallback((redirectTo?: string) => {
setLoginOpen(true);
setLoginCompletedRedirectTo(redirectTo || '');
setLoginCompletedRedirectTo(redirectTo || window.location.href);
}, []);
useEffect(() => {

View File

@ -0,0 +1,43 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import LinearProgress from '@mui/material/LinearProgress';
export default function LinearBuffer () {
const [progress, setProgress] = React.useState(0);
const [buffer, setBuffer] = React.useState(10);
const progressRef = React.useRef(() => {
//do nothing
});
React.useEffect(() => {
progressRef.current = () => {
if (progress > 100) {
setProgress(0);
setBuffer(10);
} else {
const diff = Math.random() * 10;
const diff2 = Math.random() * 10;
setProgress(progress + diff);
setBuffer(progress + diff + diff2);
}
};
});
React.useEffect(() => {
const timer = setInterval(() => {
progressRef.current();
}, 500);
return () => {
clearInterval(timer);
};
}, []);
return (
<Box sx={{ width: '100%' }}>
<LinearProgress variant="buffer" value={progress} valueBuffer={buffer} />
</Box>
);
}

View File

@ -1,28 +1,70 @@
import { getRedirectTo } from '@/application/session/sign_in';
import { NormalModal } from '@/components/_shared/modal';
import { AFConfigContext } from '@/components/app/app.hooks';
import { CircularProgress } from '@mui/material';
import { useContext, useEffect, useState } from 'react';
import LinearBuffer from '@/components/login/LinearBuffer';
import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ReactComponent as ErrorIcon } from '@/assets/error.svg';
function LoginAuth() {
function LoginAuth () {
const service = useContext(AFConfigContext)?.service;
const [loading, setLoading] = useState<boolean>(false);
const [modalOpened, setModalOpened] = useState(false);
const [error, setError] = useState<string | null>(null);
const { t } = useTranslation();
const openLoginModal = useContext(AFConfigContext)?.openLoginModal;
useEffect(() => {
void (async () => {
setLoading(true);
setError(null);
try {
await service?.loginAuth(window.location.href);
} catch (e) {
console.error(e);
// eslint-disable-next-line
} catch (e: any) {
setError(e.message);
setModalOpened(true);
} finally {
setLoading(false);
}
})();
}, [service]);
return loading ? (
<div className={'flex h-screen w-screen items-center justify-center'}>
<CircularProgress />
</div>
) : null;
const navigate = useNavigate();
return <>
{loading ? (
<div className={'flex h-screen w-screen items-center justify-center p-20'}>
<LinearBuffer />
</div>
) : null}
<NormalModal
PaperProps={{
sx: {
minWidth: 400,
},
}}
onCancel={() => {
setModalOpened(false);
navigate('/');
}}
closable={false}
cancelText={t('button.backToHome')}
onOk={() => {
openLoginModal?.(getRedirectTo() || window.location.origin);
}}
okText={t('button.tryAgain')}
title={<div className={'text-left font-bold flex gap-2 items-center'}>
<ErrorIcon className={'w-5 h-5 text-function-error'} />
Login failed
</div>}
open={modalOpened}
>
<div className={'text-text-title flex flex-col text-sm gap-1 whitespace-pre-wrap break-words'}>
{error}
</div>
</NormalModal>
</>;
}
export default LoginAuth;

View File

@ -2,8 +2,9 @@ import { PublishViewInfo } from '@/application/collab.type';
import { usePublishContext } from '@/application/publish';
import { View } from '@/application/types';
import BreadcrumbSkeleton from '@/components/_shared/skeleton/BreadcrumbSkeleton';
import { findAncestors, findView, openOrDownload } from '@/components/publish/header/utils';
import { findAncestors, findView } from '@/components/publish/header/utils';
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import { openOrDownload } from '@/utils/open_schema';
import { getPlatform } from '@/utils/platform';
import { Divider, IconButton, Tooltip } from '@mui/material';
import { debounce } from 'lodash-es';
@ -159,7 +160,7 @@ export function PublishViewHeader ({
flexItem
/>
<Tooltip title={t('publish.downloadApp')}>
<button onClick={openOrDownload}>
<button onClick={() => openOrDownload()}>
<Logo className={'h-6 w-6'} />
</button>
</Tooltip>

View File

@ -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) {

View File

@ -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<Invitation>();
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 (
<div
className={'text-text-title px-6 max-md:gap-4 flex flex-col gap-12 h-screen appflowy-scroller w-screen overflow-x-hidden overflow-y-auto items-center bg-bg-base'}
>
<div className={'flex w-full max-md:justify-center max-md:h-32 h-20 items-center justify-between sticky'}>
<AppflowyLogo className={'w-32 h-12 max-md:w-52'} />
</div>
<div className={'flex w-full max-w-[560px] flex-col items-center gap-6 text-center'}>
<Avatar
className={'h-20 w-20 text-[40px] border border-text-title rounded-[16px]'} {...workspaceIconProps}
variant="rounded"
/>
<div
className={'text-[40px] max-sm:text-[24px] px-4 whitespace-pre-wrap break-words leading-[107%] text-center'}
>
{t('invitation.join')}
{' '}
<span className={'font-semibold'}>{currentUser?.name}</span>
{' '}
{t('invitation.on')}
{' '}
<span className={'whitespace-nowrap'}>{invitation?.workspace_name}</span>
</div>
<Divider className={'max-w-full w-[400px]'} />
<div className={'flex items-center justify-center py-1 gap-4'}>
<Avatar
className={'h-20 w-20 border border-line-divider text-[40px]'} {...inviterIconProps}
variant="circular"
/>
<div className={'flex gap-1 flex-col items-start'}>
<div className={'text-base'}>{t('invitation.invitedBy')}</div>
<div className={'text-base font-semibold'}>{invitation?.inviter_name}</div>
<div className={'text-sm text-text-caption'}>{t('invitation.membersCount', {
count: invitation?.member_count || 0,
})}</div>
</div>
</div>
<div className={'text-sm max-w-full w-[400px] text-text-title'}>
{t('invitation.tip')}
</div>
<div
className={'border-b max-sm:border max-sm:rounded-[8px] border-line-border flex items-center gap-2 max-w-full py-2 px-4 w-[400px] bg-bg-body'}
>
<EmailOutlined />
{currentUser?.email}
</div>
<Button
variant={'contained'}
color={'primary'}
size={'large'}
className={'max-w-full w-[400px] rounded-[16px] text-[24px] py-5 px-10'}
onClick={async () => {
if (!invitationId) return;
if (invitation?.status === 'Accepted') {
notify.warning(t('invitation.alreadyAccepted'));
return;
}
try {
await service?.acceptInvitation(invitationId);
notify.info({
type: 'success',
title: t('invitation.success'),
message: t('invitation.successMessage'),
okText: t('invitation.openWorkspace'),
onOk: () => {
openOrDownload(openAppFlowySchema + '#workspace_id=' + invitation?.workspace_id);
},
});
} catch (e) {
notify.error('Failed to join workspace');
}
}}
>
{t('invitation.joinWorkspace')}
</Button>
</div>
<NormalModal
onCancel={() => {
setModalOpened(false);
navigate('/');
}}
closable={false}
cancelText={t('invitation.errorModal.close')}
onOk={openLoginModal}
okText={t('invitation.errorModal.changeAccount')}
title={<div className={'text-left font-bold flex gap-2 items-center'}>
<ErrorIcon className={'w-5 h-5 text-function-error'} />
{t('invitation.errorModal.title')}
</div>}
open={modalOpened}
>
<div className={'text-text-title flex flex-col text-sm gap-1 whitespace-pre-wrap break-words'}>
{t('invitation.errorModal.description', {
email: currentUser?.email,
})}
</div>
</NormalModal>
</div>
);
}
function getAvatar (item: {
icon?: string;
name: string;
}) {
if (item.icon) {
const isFlag = isFlagEmoji(item.icon);
return {
children: <span className={isFlag ? 'icon' : ''}>{item.icon}</span>,
sx: {
bgcolor: 'var(--bg-body)',
color: 'var(--text-title)',
},
};
}
return stringAvatar(item.name || '');
}
export default AcceptInvitationPage;

View File

@ -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);
};
};
};
export function openOrDownload (schema?: string) {
const os = getOS();
const downloadUrl = os === 'ios' ? iosDownloadLink : os === 'android' ? androidDownloadLink : desktopDownloadLink;
return openAppOrDownload({
appScheme: schema || openAppFlowySchema,
downloadUrl,
});
}

View File

@ -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": "Youve 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"
}
}
}