mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-11-01 10:33:29 +00:00
feat: support invitation page (#6300)
This commit is contained in:
parent
609ea27c42
commit
2c66634c89
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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';
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
199
frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx
Normal file
199
frontend/appflowy_web_app/src/pages/AcceptInvitationPage.tsx
Normal 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;
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user