feat(core): desktop multiple server support (#8979)

This commit is contained in:
EYHN
2024-12-03 05:51:09 +00:00
parent af81c95b85
commit 8963826463
137 changed files with 2052 additions and 1694 deletions
@@ -1,121 +0,0 @@
import { notify } from '@affine/component';
import {
AuthContent,
BackButton,
CountDownRender,
ModalHeader,
} from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { CaptchaService } from '@affine/core/modules/cloud';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { AuthService } from '../../../modules/cloud';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { Captcha } from './use-captcha';
export const AfterSignUpSendEmail: FC<
AuthPanelProps<'afterSignUpSendEmail'>
> = ({ setAuthData, email, redirectUrl }) => {
const [resendCountDown, setResendCountDown] = useState(60);
useEffect(() => {
const timer = setInterval(() => {
setResendCountDown(c => Math.max(c - 1, 0));
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
const [isSending, setIsSending] = useState(false);
const t = useI18n();
const authService = useService(AuthService);
const captchaService = useService(CaptchaService);
const verifyToken = useLiveData(captchaService.verifyToken$);
const needCaptcha = useLiveData(captchaService.needCaptcha$);
const challenge = useLiveData(captchaService.challenge$);
const onResendClick = useAsyncCallback(async () => {
setIsSending(true);
try {
captchaService.revalidate();
await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setResendCountDown(60);
} catch (err) {
console.error(err);
notify.error({
title: 'Failed to send email, please try again.',
});
}
setIsSending(false);
}, [authService, captchaService, challenge, email, redirectUrl, verifyToken]);
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.up']()}
subTitle={t['com.affine.auth.sign.up.sent.email.subtitle']()}
/>
<AuthContent style={{ height: 100 }}>
<Trans
i18nKey="com.affine.auth.sign.sent.email.message.sent-tips"
values={{ email }}
components={{ a: <a href={`mailto:${email}`} /> }}
/>
{t['com.affine.auth.sign.sent.email.message.sent-tips.sign-up']()}
</AuthContent>
<div className={style.resendWrapper}>
{resendCountDown <= 0 ? (
<>
<Captcha />
<Button
style={
!verifyToken && needCaptcha ? { cursor: 'not-allowed' } : {}
}
disabled={(!verifyToken && needCaptcha) || isSending}
variant="plain"
size="large"
onClick={onResendClick}
>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
</>
) : (
<div className={style.sentRow}>
<div className={style.sentMessage}>
{t['com.affine.auth.sent']()}
</div>
<CountDownRender
className={style.resendCountdown}
timeLeft={resendCountDown}
/>
</div>
)}
</div>
<div className={style.authMessage} style={{ marginTop: 20 }}>
{t['com.affine.auth.sign.auth.code.message']()}
</div>
<BackButton
onClick={useCallback(() => {
setAuthData({ state: 'signIn' });
}, [setAuthData])}
/>
</>
);
};
@@ -1,7 +1,8 @@
import { useConfirmModal } from '@affine/component';
import { authAtom } from '@affine/core/components/atoms';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { atom, useAtom, useSetAtom } from 'jotai';
import { useService } from '@toeverything/infra';
import { atom, useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
export const showAILoginRequiredAtom = atom(false);
@@ -9,12 +10,12 @@ export const showAILoginRequiredAtom = atom(false);
export const AiLoginRequiredModal = () => {
const t = useI18n();
const [open, setOpen] = useAtom(showAILoginRequiredAtom);
const setAuth = useSetAtom(authAtom);
const globalDialogService = useService(GlobalDialogService);
const { openConfirmModal, closeConfirmModal } = useConfirmModal();
const openSignIn = useCallback(() => {
setAuth(prev => ({ ...prev, openModal: true }));
}, [setAuth]);
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
useEffect(() => {
if (open) {
@@ -1,114 +0,0 @@
import { notify } from '@affine/component';
import { AuthModal as AuthModalBase } from '@affine/component/auth-components';
import { authAtom, type AuthAtomData } from '@affine/core/components/atoms';
import { AuthService } from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useAtom } from 'jotai/react';
import type { FC } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { AfterSignInSendEmail } from './after-sign-in-send-email';
import { AfterSignUpSendEmail } from './after-sign-up-send-email';
import { SendEmail } from './send-email';
import { SignIn } from './sign-in';
import { SignInWithPassword } from './sign-in-with-password';
type AuthAtomType<T extends AuthAtomData['state']> = Extract<
AuthAtomData,
{ state: T }
>;
// return field in B that is not in A
type Difference<
A extends Record<string, any>,
B extends Record<string, any>,
> = Pick<B, Exclude<keyof B, keyof A>>;
export type AuthPanelProps<State extends AuthAtomData['state']> = {
setAuthData: <T extends AuthAtomData['state']>(
updates: { state: T } & Difference<AuthAtomType<State>, AuthAtomType<T>>
) => void;
onSkip?: () => void;
redirectUrl?: string;
} & Extract<AuthAtomData, { state: State }>;
const config: {
[k in AuthAtomData['state']]: FC<AuthPanelProps<k>>;
} = {
signIn: SignIn,
afterSignUpSendEmail: AfterSignUpSendEmail,
afterSignInSendEmail: AfterSignInSendEmail,
signInWithPassword: SignInWithPassword,
sendEmail: SendEmail,
};
export function AuthModal() {
const [authAtomValue, setAuthAtom] = useAtom(authAtom);
const setOpen = useCallback(
(open: boolean) => {
setAuthAtom(prev => ({ ...prev, openModal: open }));
},
[setAuthAtom]
);
return (
<AuthModalBase open={authAtomValue.openModal} setOpen={setOpen}>
<AuthPanel />
</AuthModalBase>
);
}
export function AuthPanel({
onSkip,
redirectUrl,
}: {
onSkip?: () => void;
redirectUrl?: string | null;
}) {
const t = useI18n();
const [authAtomValue, setAuthAtom] = useAtom(authAtom);
const authService = useService(AuthService);
const loginStatus = useLiveData(authService.session.status$);
const previousLoginStatus = useRef(loginStatus);
const setAuthData = useCallback(
(updates: Partial<AuthAtomData>) => {
// @ts-expect-error checked in impls
setAuthAtom(prev => ({
...prev,
...updates,
}));
},
[setAuthAtom]
);
useEffect(() => {
if (
loginStatus === 'authenticated' &&
previousLoginStatus.current === 'unauthenticated'
) {
setAuthAtom({
openModal: false,
state: 'signIn',
});
notify.success({
title: t['com.affine.auth.toast.title.signed-in'](),
message: t['com.affine.auth.toast.message.signed-in'](),
});
}
previousLoginStatus.current = loginStatus;
}, [loginStatus, setAuthAtom, t]);
const CurrentPanel = config[authAtomValue.state];
const props = {
...authAtomValue,
onSkip,
redirectUrl,
setAuthData,
};
// @ts-expect-error checked in impls
return <CurrentPanel {...props} />;
}
@@ -1,4 +1,3 @@
import { Skeleton } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { ServerService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
@@ -38,7 +37,7 @@ export function OAuth({ redirectUrl }: { redirectUrl?: string }) {
const scheme = urlService.getClientScheme();
if (!oauth) {
return <Skeleton height={50} />;
return null;
}
return oauthProviders?.map(provider => (
@@ -1,221 +0,0 @@
import { notify, Wrapper } from '@affine/component';
import {
AuthContent,
AuthInput,
BackButton,
ModalHeader,
} from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
sendChangeEmailMutation,
sendChangePasswordEmailMutation,
sendSetPasswordEmailMutation,
sendVerifyEmailMutation,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { useMutation } from '../../../components/hooks/use-mutation';
import { ServerService } from '../../../modules/cloud';
import type { AuthPanelProps } from './index';
const useEmailTitle = (emailType: AuthPanelProps<'sendEmail'>['emailType']) => {
const t = useI18n();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.set.password']();
case 'changePassword':
return t['com.affine.auth.reset.password']();
case 'changeEmail':
return t['com.affine.settings.email.action.change']();
case 'verifyEmail':
return t['com.affine.settings.email.action.verify']();
}
};
const useNotificationHint = (
emailType: AuthPanelProps<'sendEmail'>['emailType']
) => {
const t = useI18n();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.sent.set.password.hint']();
case 'changePassword':
return t['com.affine.auth.sent.change.password.hint']();
case 'changeEmail':
case 'verifyEmail':
return t['com.affine.auth.sent.verify.email.hint']();
}
};
const useButtonContent = (
emailType: AuthPanelProps<'sendEmail'>['emailType']
) => {
const t = useI18n();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.send.set.password.link']();
case 'changePassword':
return t['com.affine.auth.send.reset.password.link']();
case 'changeEmail':
case 'verifyEmail':
return t['com.affine.auth.send.verify.email.hint']();
}
};
const useSendEmail = (emailType: AuthPanelProps<'sendEmail'>['emailType']) => {
const {
trigger: sendChangePasswordEmail,
isMutating: isChangePasswordMutating,
} = useMutation({
mutation: sendChangePasswordEmailMutation,
});
const { trigger: sendSetPasswordEmail, isMutating: isSetPasswordMutating } =
useMutation({
mutation: sendSetPasswordEmailMutation,
});
const { trigger: sendChangeEmail, isMutating: isChangeEmailMutating } =
useMutation({
mutation: sendChangeEmailMutation,
});
const { trigger: sendVerifyEmail, isMutating: isVerifyEmailMutation } =
useMutation({
mutation: sendVerifyEmailMutation,
});
return {
loading:
isChangePasswordMutating ||
isSetPasswordMutating ||
isChangeEmailMutating ||
isVerifyEmailMutation,
sendEmail: useCallback(
(email: string) => {
let trigger: (args: {
email: string;
callbackUrl: string;
}) => Promise<unknown>;
let callbackUrl;
switch (emailType) {
case 'setPassword':
trigger = sendSetPasswordEmail;
callbackUrl = 'setPassword';
break;
case 'changePassword':
trigger = sendChangePasswordEmail;
callbackUrl = 'changePassword';
break;
case 'changeEmail':
trigger = sendChangeEmail;
callbackUrl = 'changeEmail';
break;
case 'verifyEmail':
trigger = sendVerifyEmail;
callbackUrl = 'verify-email';
break;
}
// TODO(@eyhn): add error handler
return trigger({
email,
callbackUrl: `/auth/${callbackUrl}`,
});
},
[
emailType,
sendChangeEmail,
sendChangePasswordEmail,
sendSetPasswordEmail,
sendVerifyEmail,
]
),
};
};
export const SendEmail = ({
setAuthData,
email,
emailType,
// todo(@pengx17): impl redirectUrl for sendEmail?
}: AuthPanelProps<'sendEmail'>) => {
const t = useI18n();
const serverService = useService(ServerService);
const passwordLimits = useLiveData(
serverService.server.credentialsRequirement$.map(r => r?.password)
);
const [hasSentEmail, setHasSentEmail] = useState(false);
const title = useEmailTitle(emailType);
const hint = useNotificationHint(emailType);
const buttonContent = useButtonContent(emailType);
const { loading, sendEmail } = useSendEmail(emailType);
const onSendEmail = useAsyncCallback(async () => {
// TODO(@eyhn): add error handler
await sendEmail(email);
notify.success({ title: hint });
setHasSentEmail(true);
}, [email, hint, sendEmail]);
const onBack = useCallback(() => {
setAuthData({ state: 'signIn' });
}, [setAuthData]);
if (!passwordLimits) {
// TODO(@eyhn): loading & error UI
return null;
}
const content =
emailType === 'setPassword'
? t['com.affine.auth.set.password.message']({
min: String(passwordLimits.minLength),
max: String(passwordLimits.maxLength),
})
: emailType === 'changePassword'
? t['com.affine.auth.reset.password.message']()
: emailType === 'changeEmail' || emailType === 'verifyEmail'
? t['com.affine.auth.verify.email.message']({ email })
: null;
return (
<>
<ModalHeader
title={t['com.affine.brand.affineCloud']()}
subTitle={title}
/>
<AuthContent>{content}</AuthContent>
<Wrapper
marginTop={30}
marginBottom={50}
style={{
position: 'relative',
}}
>
<AuthInput
label={t['com.affine.settings.email']()}
disabled={true}
value={email}
/>
</Wrapper>
<Button
variant="primary"
size="extraLarge"
style={{ width: '100%' }}
disabled={hasSentEmail}
loading={loading}
onClick={onSendEmail}
>
{hasSentEmail ? t['com.affine.auth.sent']() : buttonContent}
</Button>
<BackButton onClick={onBack} />
</>
);
};
@@ -1,184 +0,0 @@
import { notify } from '@affine/component';
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { CaptchaService } from '@affine/core/modules/cloud';
import { Trans, useI18n } from '@affine/i18n';
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import type { FC } from 'react';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AuthService } from '../../../modules/cloud';
import { emailRegex } from '../../../utils/email-regex';
import type { AuthPanelProps } from './index';
import { OAuth } from './oauth';
import * as style from './style.css';
import { Captcha } from './use-captcha';
function validateEmail(email: string) {
return emailRegex.test(email);
}
export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
setAuthData: setAuthState,
onSkip,
redirectUrl,
}) => {
const t = useI18n();
const authService = useService(AuthService);
const [searchParams] = useSearchParams();
const [isMutating, setIsMutating] = useState(false);
const captchaService = useService(CaptchaService);
const verifyToken = useLiveData(captchaService.verifyToken$);
const needCaptcha = useLiveData(captchaService.needCaptcha$);
const challenge = useLiveData(captchaService.challenge$);
const [email, setEmail] = useState('');
const [isValidEmail, setIsValidEmail] = useState(true);
const errorMsg = searchParams.get('error');
const onContinue = useAsyncCallback(async () => {
if (!validateEmail(email)) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);
setIsMutating(true);
try {
const { hasPassword, registered } =
await authService.checkUserByEmail(email);
if (registered) {
// provider password sign-in if user has by default
// If with payment, onl support email sign in to avoid redirect to affine app
if (hasPassword) {
setAuthState({
state: 'signInWithPassword',
email,
});
} else {
captchaService.revalidate();
await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setAuthState({
state: 'afterSignInSendEmail',
email,
});
}
} else {
captchaService.revalidate();
await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setAuthState({
state: 'afterSignUpSendEmail',
email,
});
}
} catch (err) {
console.error(err);
// TODO(@eyhn): better error handling
notify.error({
title: 'Failed to send email. Please try again.',
});
}
setIsMutating(false);
}, [
authService,
captchaService,
challenge,
email,
redirectUrl,
setAuthState,
verifyToken,
]);
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={t['com.affine.brand.affineCloud']()}
/>
<OAuth redirectUrl={redirectUrl} />
<div className={style.authModalContent}>
<AuthInput
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
onChange={setEmail}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
/>
{verifyToken || !needCaptcha ? (
<Button
style={{ width: '100%' }}
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating}
suffix={<ArrowRightBigIcon />}
suffixStyle={{ width: 20, height: 20, color: cssVar('blue') }}
onClick={onContinue}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
) : (
<Captcha />
)}
{errorMsg && <div className={style.errorMessage}>{errorMsg}</div>}
<div className={style.authMessage}>
{/*prettier-ignore*/}
<Trans i18nKey="com.affine.auth.sign.message">
By clicking &quot;Continue with Google/Email&quot; above, you acknowledge that
you agree to AFFiNE&apos;s <a href="https://affine.pro/terms" target="_blank" rel="noreferrer">Terms of Conditions</a> and <a href="https://affine.pro/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>.
</Trans>
</div>
</div>
{onSkip ? (
<>
<div className={style.skipDivider}>
<div className={style.skipDividerLine} />
<span className={style.skipDividerText}>or</span>
<div className={style.skipDividerLine} />
</div>
<div className={style.skipSection}>
<div className={style.skipText}>
{t['com.affine.mobile.sign-in.skip.hint']()}
</div>
<Button
variant="plain"
onClick={onSkip}
className={style.skipLink}
suffix={<ArrowRightBigIcon className={style.skipLinkIcon} />}
>
{t['com.affine.mobile.sign-in.skip.link']()}
</Button>
</div>
</>
) : null}
</>
);
};
@@ -1,99 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const authModalContent = style({
marginTop: '30px',
});
export const captchaWrapper = style({
margin: 'auto',
marginBottom: '4px',
textAlign: 'center',
});
export const authMessage = style({
marginTop: '30px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
lineHeight: 1.5,
});
import { style } from '@vanilla-extract/css';
export const errorMessage = style({
marginTop: '30px',
color: cssVar('textHighlightForegroundRed'),
fontSize: cssVar('fontXs'),
lineHeight: 1.5,
});
globalStyle(`${authMessage} a`, {
color: cssVar('linkColor'),
});
globalStyle(`${authMessage} .link`, {
cursor: 'pointer',
color: cssVar('linkColor'),
});
export const forgetPasswordButtonRow = style({
position: 'absolute',
right: 0,
marginTop: '-26px', // Let this button be a tail of password input.
});
export const sendMagicLinkButtonRow = style({
marginBottom: '30px',
});
export const linkButton = style({
color: cssVar('linkColor'),
background: 'transparent',
borderColor: 'transparent',
fontSize: cssVar('fontXs'),
lineHeight: '22px',
userSelect: 'none',
});
export const forgetPasswordButton = style({
fontSize: cssVar('fontSm'),
color: cssVar('textSecondaryColor'),
position: 'absolute',
right: 0,
bottom: 0,
});
export const resendWrapper = style({
height: 77,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
marginTop: 30,
});
export const sentRow = style({
display: 'flex',
justifyContent: 'center',
gap: '8px',
lineHeight: '22px',
fontSize: cssVar('fontSm'),
});
export const sentMessage = style({
color: cssVar('textPrimaryColor'),
fontWeight: 600,
});
export const resendCountdown = style({
width: 45,
textAlign: 'center',
});
export const resendCountdownInButton = style({
width: 40,
textAlign: 'center',
fontSize: cssVar('fontSm'),
marginLeft: 16,
color: cssVar('blue'),
fontWeight: 400,
});
export const accessMessage = style({
textAlign: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: cssVar('fontXs'),
fontWeight: 500,
marginTop: 65,
marginBottom: 40,
});
export const userPlanButton = style({
display: 'flex',
fontSize: cssVar('fontXs'),
@@ -114,44 +21,3 @@ export const userPlanButton = style({
},
},
});
export const skipDivider = style({
display: 'flex',
gap: 12,
alignItems: 'center',
height: 20,
marginTop: 12,
marginBottom: 12,
});
export const skipDividerLine = style({
flex: 1,
height: 0,
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const skipDividerText = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontXs'),
});
export const skipText = style({
color: cssVarV2('text/primary'),
fontSize: cssVar('fontXs'),
fontWeight: 500,
});
export const skipLink = style({
color: cssVarV2('text/link'),
fontSize: cssVar('fontXs'),
});
export const skipLinkIcon = style({
color: cssVarV2('text/link'),
});
export const skipSection = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
});
@@ -1,27 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const loadingContainer = style({
display: 'flex',
width: '100vw',
height: '60vh',
justifyContent: 'center',
alignItems: 'center',
});
export const subscriptionLayout = style({
margin: '10% auto',
maxWidth: '536px',
});
export const subscriptionBox = style({
padding: '48px 52px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
});
export const subscriptionTips = style({
margin: '20px 0',
color: cssVar('textSecondaryColor'),
fontSize: '12px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: '20px',
});
@@ -2,7 +2,6 @@ import { Tabs, Tooltip } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import { Menu } from '@affine/component/ui/menu';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import type { Doc } from '@blocksuite/affine/store';
import { LockIcon, PublishIcon } from '@blocksuite/icons/rc';
@@ -121,7 +120,7 @@ const CloudShareMenu = (props: ShareMenuProps) => {
export const ShareMenu = (props: ShareMenuProps) => {
const { workspaceMetadata } = props;
if (workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) {
if (workspaceMetadata.flavour === 'local') {
return <LocalShareMenu {...props} />;
}
return <CloudShareMenu {...props} />;
@@ -11,7 +11,6 @@ import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { EditorService } from '@affine/core/modules/editor';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { PublicPageMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
@@ -316,11 +315,9 @@ export const AFFiNESharePage = (props: ShareMenuProps) => {
};
export const SharePage = (props: ShareMenuProps) => {
if (props.workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) {
if (props.workspaceMetadata.flavour === 'local') {
return <LocalSharePage {...props} />;
} else if (
props.workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD
) {
} else {
return (
// TODO(@eyhn): refactor this part
<ErrorBoundary fallback={null}>
@@ -330,5 +327,4 @@ export const SharePage = (props: ShareMenuProps) => {
</ErrorBoundary>
);
}
throw new Error('Unreachable');
};
@@ -1,48 +1,11 @@
import { atom } from 'jotai';
// modal atoms
export const openWorkspacesModalAtom = atom(false);
/**
* @deprecated use `useSignOut` hook instated
*/
export const openQuotaModalAtom = atom(false);
export const rightSidebarWidthAtom = atom(320);
export const openImportModalAtom = atom(false);
export type AuthAtomData =
| { state: 'signIn' }
| {
state: 'afterSignUpSendEmail';
email: string;
}
| {
state: 'afterSignInSendEmail';
email: string;
}
| {
state: 'signInWithPassword';
email: string;
}
| {
state: 'sendEmail';
email: string;
emailType:
| 'setPassword'
| 'changePassword'
| 'changeEmail'
| 'verifyEmail';
};
export const authAtom = atom<
AuthAtomData & {
openModal: boolean;
}
>({
openModal: false,
state: 'signIn',
});
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');
@@ -1,13 +1,12 @@
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { toggleGeneralAIOnboarding } from '@affine/core/components/affine/ai-onboarding/apis';
import { authAtom } from '@affine/core/components/atoms';
import type { GlobalDialogService } from '@affine/core/modules/dialogs';
import {
type getCopilotHistoriesQuery,
type RequestOptions,
} from '@affine/graphql';
import { UnauthorizedError } from '@blocksuite/affine/blocks';
import { assertExists } from '@blocksuite/affine/global/utils';
import { getCurrentStore } from '@toeverything/infra';
import { z } from 'zod';
import type { CopilotClient } from './copilot-client';
@@ -42,7 +41,10 @@ const processTypeToPromptName = new Map(
// user-id:workspace-id:doc-id -> chat session id
const chatSessions = new Map<string, Promise<string>>();
export function setupAIProvider(client: CopilotClient) {
export function setupAIProvider(
client: CopilotClient,
globalDialogService: GlobalDialogService
) {
async function getChatSessionId(workspaceId: string, docId: string) {
const userId = (await AIProvider.userInfo)?.id;
@@ -499,10 +501,7 @@ Could you make a new website based on these notes and send back just the html fi
});
const disposeRequestLoginHandler = AIProvider.slots.requestLogin.on(() => {
getCurrentStore().set(authAtom, s => ({
...s,
openModal: true,
}));
globalDialogService.open('sign-in', {});
});
setupTracker();
@@ -19,7 +19,6 @@ import { EditorService } from '@affine/core/modules/editor';
import { OpenInAppService } from '@affine/core/modules/open-in-app/services';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import type { Doc } from '@blocksuite/affine/store';
@@ -111,7 +110,7 @@ export const PageHeaderMenuButton = ({
const openHistoryModal = useCallback(() => {
track.$.header.history.open();
if (workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
if (workspace.flavour === 'affine-cloud') {
return setHistoryModalOpen(true);
}
return setOpenHistoryTipsModal(true);
@@ -398,8 +397,7 @@ export const PageHeaderMenuButton = ({
data-testid="editor-option-menu-delete"
onSelect={handleOpenTrashModal}
/>
{BUILD_CONFIG.isWeb &&
workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
{BUILD_CONFIG.isWeb && workspace.flavour === 'affine-cloud' ? (
<MenuItem
prefixIcon={<LocalWorkspaceIcon />}
data-testid="editor-option-menu-link"
@@ -426,7 +424,7 @@ export const PageHeaderMenuButton = ({
>
<HeaderDropDownButton />
</Menu>
{workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
{workspace.flavour !== 'local' ? (
<PageHistoryModal
docCollection={workspace.docCollection}
open={historyModalOpen}
@@ -1,22 +1,19 @@
import { Button } from '@affine/component/ui/button';
import { authAtom } from '@affine/core/components/atoms';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { useSetAtom } from 'jotai';
import { useService } from '@toeverything/infra';
import { useCallback } from 'react';
import * as styles from './styles.css';
export const SignIn = () => {
const setOpen = useSetAtom(authAtom);
const globalDialogService = useService(GlobalDialogService);
const t = useI18n();
const onClickSignIn = useCallback(() => {
setOpen(state => ({
...state,
openModal: true,
}));
}, [setOpen]);
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
return (
<Button
@@ -1,6 +1,5 @@
import { Avatar, PropertyValue } from '@affine/component';
import { CloudDocMetaService } from '@affine/core/modules/cloud/services/cloud-doc-meta';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
@@ -73,8 +72,7 @@ const LocalUserValue = () => {
export const CreatedByValue = () => {
const workspaceService = useService(WorkspaceService);
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const isCloud = workspaceService.workspace.flavour !== 'local';
if (!isCloud) {
return (
@@ -93,8 +91,7 @@ export const CreatedByValue = () => {
export const UpdatedByValue = () => {
const workspaceService = useService(WorkspaceService);
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const isCloud = workspaceService.workspace.flavour !== 'local';
if (!isCloud) {
return (
@@ -1,6 +1,6 @@
import { notify, useConfirmModal } from '@affine/component';
import { authAtom } from '@affine/core/components/atoms';
import { AuthService } from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import type { Workspace } from '@toeverything/infra';
import {
@@ -8,7 +8,6 @@ import {
useService,
WorkspacesService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { useNavigateHelper } from '../use-navigate-helper';
@@ -31,7 +30,7 @@ export const useEnableCloud = () => {
const authService = useService(AuthService);
const account = useLiveData(authService.session.account$);
const loginStatus = useLiveData(useService(AuthService).session.status$);
const setAuthAtom = useSetAtom(authAtom);
const globalDialogService = useService(GlobalDialogService);
const { openConfirmModal, closeConfirmModal } = useConfirmModal();
const workspacesService = useService(WorkspacesService);
const { jumpToPage } = useNavigateHelper();
@@ -58,8 +57,8 @@ export const useEnableCloud = () => {
);
const openSignIn = useCallback(() => {
setAuthAtom(prev => ({ ...prev, openModal: true }));
}, [setAuthAtom]);
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
const signInOrEnableCloud = useCallback(
async (...args: ConfirmEnableArgs) => {
@@ -8,7 +8,6 @@ import type { Editor } from '@affine/core/modules/editor';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { OpenInAppService } from '@affine/core/modules/open-in-app';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import {
@@ -78,7 +77,7 @@ export function useRegisterBlocksuiteEditorCommands(editor: Editor) {
});
}, [doc, openConfirmModal, t]);
const isCloudWorkspace = workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const isCloudWorkspace = workspace.flavour !== 'local';
const openInAppService = useServiceOptional(OpenInAppService);
@@ -4,7 +4,6 @@ import {
} from '@affine/core/commands';
import { useSharingUrl } from '@affine/core/components/hooks/affine/use-share-url';
import { useIsActiveView } from '@affine/core/modules/workbench';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { track } from '@affine/track';
import { type WorkspaceMetadata } from '@toeverything/infra';
import { useEffect } from 'react';
@@ -18,7 +17,7 @@ export function useRegisterCopyLinkCommands({
}) {
const isActiveView = useIsActiveView();
const workspaceId = workspaceMeta.id;
const isCloud = workspaceMeta.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const isCloud = workspaceMeta.flavour !== 'local';
const { onClickCopyLink } = useSharingUrl({
workspaceId,
@@ -3,8 +3,7 @@ import {
notify,
useConfirmModal,
} from '@affine/component';
import { AuthService } from '@affine/core/modules/cloud';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { AuthService, ServerService } from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n';
import {
GlobalContextService,
@@ -32,18 +31,14 @@ export const useSignOut = ({
const { openConfirmModal } = useConfirmModal();
const { openPage } = useNavigateHelper();
const serverService = useService(ServerService);
const authService = useService(AuthService);
const workspacesService = useService(WorkspacesService);
const globalContextService = useService(GlobalContextService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const currentWorkspaceId = useLiveData(
globalContextService.globalContext.workspaceId.$
);
const currentWorkspaceMetadata = useLiveData(
currentWorkspaceId
? workspacesService.list.workspace$(currentWorkspaceId)
: undefined
const currentWorkspaceFlavour = useLiveData(
globalContextService.globalContext.workspaceFlavour.$
);
const signOut = useCallback(async () => {
@@ -58,10 +53,10 @@ export const useSignOut = ({
});
}
// if current workspace is affine cloud, switch to local workspace
if (currentWorkspaceMetadata?.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
// if current workspace is sign out, switch to other workspace
if (currentWorkspaceFlavour === serverService.server.id) {
const localWorkspace = workspaces.find(
w => w.flavour === WorkspaceFlavour.LOCAL
w => w.flavour !== serverService.server.id
);
if (localWorkspace) {
openPage(localWorkspace.id, 'all');
@@ -69,9 +64,10 @@ export const useSignOut = ({
}
}, [
authService,
currentWorkspaceMetadata?.flavour,
currentWorkspaceFlavour,
onConfirm,
openPage,
serverService.server.id,
workspaces,
]);
@@ -41,7 +41,6 @@ import {
switchMap,
timeout,
} from 'rxjs';
import { Map as YMap } from 'yjs';
import { CopilotClient } from '../blocksuite/block-suite-editor/ai/copilot-client';
import { setupAIProvider } from '../blocksuite/block-suite-editor/ai/setup-provider';
@@ -147,38 +146,18 @@ export const WorkspaceSideEffects = () => {
graphqlService.gql,
fetchService.fetch,
eventSourceService.eventSource
)
),
globalDialogService
);
return () => {
dispose();
};
}, [eventSourceService, fetchService, graphqlService]);
}, [eventSourceService, fetchService, globalDialogService, graphqlService]);
useRegisterWorkspaceCommands();
useRegisterNavigationCommands();
useRegisterFindInPageCommands();
useEffect(() => {
// hotfix for blockVersions
// this is a mistake in the
// 0.8.0 ~ 0.8.1
// 0.8.0-beta.0 ~ 0.8.0-beta.3
// 0.8.0-canary.17 ~ 0.9.0-canary.3
const meta = currentWorkspace.docCollection.doc.getMap('meta');
const blockVersions = meta.get('blockVersions');
if (
!(blockVersions instanceof YMap) &&
blockVersions !== null &&
blockVersions !== undefined &&
typeof blockVersions === 'object'
) {
meta.set(
'blockVersions',
new YMap(Object.entries(blockVersions as Record<string, number>))
);
}
}, [currentWorkspace.docCollection.doc]);
return (
<>
<QuickSearchContainer />
@@ -8,7 +8,6 @@ import {
type MenuProps,
Skeleton,
} from '@affine/component';
import { authAtom } from '@affine/core/components/atoms';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
@@ -17,7 +16,6 @@ import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import {
@@ -58,11 +56,11 @@ const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => {
};
const UnauthorizedUserInfo = () => {
const setOpen = useSetAtom(authAtom);
const globalDialogService = useService(GlobalDialogService);
const openSignInModal = useCallback(() => {
setOpen(state => ({ ...state, openModal: true }));
}, [setOpen]);
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
return (
<IconButton
@@ -0,0 +1,127 @@
import { Button } from '@affine/component';
import {
AuthContent,
AuthInput,
ModalHeader,
} from '@affine/component/auth-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { ServersService } from '@affine/core/modules/cloud';
import { Trans, useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import type { SignInState } from '.';
import * as styles from './style.css';
function normalizeURL(url: string) {
const normalized = new URL(url).toString();
return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;
}
export const AddSelfhostedStep = ({
state,
changeState,
}: {
state: SignInState;
changeState: Dispatch<SetStateAction<SignInState>>;
}) => {
const serversService = useService(ServersService);
const [baseURL, setBaseURL] = useState(state.initialServerBaseUrl ?? '');
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<boolean>(false);
const t = useI18n();
const urlValid = useMemo(() => {
try {
normalizeURL(baseURL);
return true;
} catch {
return false;
}
}, [baseURL]);
const onBaseURLChange = useCallback((value: string) => {
setBaseURL(value);
setError(false);
}, []);
const onConnect = useAsyncCallback(async () => {
setIsConnecting(true);
try {
const server = await serversService.addOrGetServerByBaseUrl(
normalizeURL(baseURL)
);
changeState(prev => ({
...prev,
step: 'signIn',
server,
}));
} catch (err) {
console.error(err);
setError(true);
}
setIsConnecting(false);
}, [baseURL, changeState, serversService]);
useEffect(() => {
if (state.initialServerBaseUrl) {
changeState(prev => ({
...prev,
initialServerBaseUrl: undefined,
}));
onConnect();
}
}, [changeState, onConnect, state]);
return (
<>
<ModalHeader title={t['com.affine.auth.sign.add-selfhosted']()} />
<AuthContent>
<AuthInput
label={t['com.affine.auth.sign.add-selfhosted.baseurl']()}
value={baseURL}
onChange={onBaseURLChange}
placeholder="https://your-server.com"
error={!!error}
disabled={isConnecting}
errorHint={t['com.affine.auth.sign.add-selfhosted.error']()}
onEnter={onConnect}
/>
<Button
data-testid="connect-selfhosted-button"
variant="primary"
size="extraLarge"
style={{ width: '100%', marginTop: '16px' }}
disabled={!urlValid || isConnecting}
loading={isConnecting}
onClick={onConnect}
>
{t['com.affine.auth.sign.add-selfhosted.connect-button']()}
</Button>
<div className={styles.authMessage}>
<Trans
i18nKey="com.affine.auth.sign.add-selfhosted.description"
components={{
1: (
<a
href="https://docs.affine.pro/docs/self-host-affine"
target="_blank"
rel="noreferrer"
/>
),
}}
/>
</div>
</AuthContent>
</>
);
};
@@ -0,0 +1,62 @@
import { DefaultServerService, type Server } from '@affine/core/modules/cloud';
import { FrameworkScope, useService } from '@toeverything/infra';
import { useState } from 'react';
import { AddSelfhostedStep } from './add-selfhosted';
import { SignInStep } from './sign-in';
import { SignInWithEmailStep } from './sign-in-with-email';
import { SignInWithPasswordStep } from './sign-in-with-password';
export type SignInStep =
| 'signIn'
| 'signInWithPassword'
| 'signInWithEmail'
| 'addSelfhosted';
export interface SignInState {
step: SignInStep;
server?: Server;
initialServerBaseUrl?: string;
email?: string;
redirectUrl?: string;
}
export const SignInPanel = ({
onClose,
server: initialServerBaseUrl,
}: {
onClose: () => void;
server?: string;
}) => {
const [state, setState] = useState<SignInState>({
step: initialServerBaseUrl ? 'addSelfhosted' : 'signIn',
initialServerBaseUrl: initialServerBaseUrl,
});
const defaultServerService = useService(DefaultServerService);
const step = state.step;
const server = state.server ?? defaultServerService.server;
return (
<FrameworkScope scope={server.scope}>
{step === 'signIn' ? (
<SignInStep state={state} changeState={setState} close={onClose} />
) : step === 'signInWithEmail' ? (
<SignInWithEmailStep
state={state}
changeState={setState}
close={onClose}
/>
) : step === 'signInWithPassword' ? (
<SignInWithPasswordStep
state={state}
changeState={setState}
close={onClose}
/>
) : step === 'addSelfhosted' ? (
<AddSelfhostedStep state={state} changeState={setState} />
) : null}
</FrameworkScope>
);
};
@@ -8,21 +8,38 @@ import {
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, CaptchaService } from '@affine/core/modules/cloud';
import { Unreachable } from '@affine/env/constant';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import type { AuthPanelProps } from './index';
import type { SignInState } from '.';
import { Captcha } from './captcha';
import * as style from './style.css';
import { Captcha } from './use-captcha';
export const AfterSignInSendEmail = ({
setAuthData: setAuth,
email,
redirectUrl,
}: AuthPanelProps<'afterSignInSendEmail'>) => {
export const SignInWithEmailStep = ({
state,
changeState,
close,
}: {
state: SignInState;
changeState: Dispatch<SetStateAction<SignInState>>;
close: () => void;
}) => {
const [resendCountDown, setResendCountDown] = useState(60);
const email = state.email;
if (!email) {
throw new Unreachable();
}
useEffect(() => {
const timer = setInterval(() => {
setResendCountDown(c => Math.max(c - 1, 0));
@@ -43,7 +60,20 @@ export const AfterSignInSendEmail = ({
const needCaptcha = useLiveData(captchaService.needCaptcha$);
const challenge = useLiveData(captchaService.challenge$);
const loginStatus = useLiveData(authService.session.status$);
useEffect(() => {
if (loginStatus === 'authenticated') {
close();
notify.success({
title: t['com.affine.auth.toast.title.signed-in'](),
message: t['com.affine.auth.toast.message.signed-in'](),
});
}
}, [close, loginStatus, t]);
const onResendClick = useAsyncCallback(async () => {
if (isSending || (!verifyToken && needCaptcha)) return;
setIsSending(true);
try {
setResendCountDown(60);
@@ -52,7 +82,7 @@ export const AfterSignInSendEmail = ({
email,
verifyToken,
challenge,
redirectUrl
state.redirectUrl
);
} catch (err) {
console.error(err);
@@ -61,17 +91,34 @@ export const AfterSignInSendEmail = ({
});
}
setIsSending(false);
}, [authService, captchaService, challenge, email, redirectUrl, verifyToken]);
}, [
authService,
captchaService,
challenge,
email,
isSending,
needCaptcha,
state.redirectUrl,
verifyToken,
]);
const onSignInWithPasswordClick = useCallback(() => {
setAuth({ state: 'signInWithPassword' });
}, [setAuth]);
changeState(prev => ({ ...prev, step: 'signInWithPassword' }));
}, [changeState]);
const onBackBottomClick = useCallback(() => {
setAuth({ state: 'signIn' });
}, [setAuth]);
changeState(prev => ({ ...prev, step: 'signIn' }));
}, [changeState]);
return (
return !verifyToken && needCaptcha ? (
<>
<ModalHeader title={t['com.affine.auth.sign.in']()} />
<AuthContent style={{ height: 100 }}>
<Captcha />
</AuthContent>
<BackButton onClick={onBackBottomClick} />
</>
) : (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
@@ -88,20 +135,14 @@ export const AfterSignInSendEmail = ({
<div className={style.resendWrapper}>
{resendCountDown <= 0 ? (
<>
<Captcha />
<Button
style={
!verifyToken && needCaptcha ? { cursor: 'not-allowed' } : {}
}
disabled={(!verifyToken && needCaptcha) || isSending}
variant="plain"
size="large"
onClick={onResendClick}
>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
</>
<Button
disabled={isSending}
variant="plain"
size="large"
onClick={onResendClick}
>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
) : (
<div className={style.sentRow}>
<div className={style.sentMessage}>
@@ -6,27 +6,52 @@ import {
} from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, CaptchaService } from '@affine/core/modules/cloud';
import {
AuthService,
CaptchaService,
ServerService,
} from '@affine/core/modules/cloud';
import { Unreachable } from '@affine/env/constant';
import { ServerDeploymentType } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { AuthPanelProps } from './index';
import type { SignInState } from '.';
import { Captcha } from './captcha';
import * as styles from './style.css';
import { Captcha } from './use-captcha';
export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
setAuthData,
email,
redirectUrl,
export const SignInWithPasswordStep = ({
state,
changeState,
close,
}: {
state: SignInState;
changeState: Dispatch<SetStateAction<SignInState>>;
close: () => void;
}) => {
const t = useI18n();
const authService = useService(AuthService);
const email = state.email;
if (!email) {
throw new Unreachable();
}
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState(false);
const captchaService = useService(CaptchaService);
const serverService = useService(ServerService);
const isSelfhosted = useLiveData(
serverService.server.config$.selector(
c => c.type === ServerDeploymentType.Selfhosted
)
);
const serverName = useLiveData(
serverService.server.config$.selector(c => c.serverName)
);
const verifyToken = useLiveData(captchaService.verifyToken$);
const needCaptcha = useLiveData(captchaService.needCaptcha$);
@@ -34,8 +59,20 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
const [isLoading, setIsLoading] = useState(false);
const [sendingEmail, setSendingEmail] = useState(false);
const loginStatus = useLiveData(authService.session.status$);
useEffect(() => {
if (loginStatus === 'authenticated') {
close();
notify.success({
title: t['com.affine.auth.toast.title.signed-in'](),
message: t['com.affine.auth.toast.message.signed-in'](),
});
}
}, [close, loginStatus, t]);
const onSignIn = useAsyncCallback(async () => {
if (isLoading) return;
if (isLoading || (!verifyToken && needCaptcha)) return;
setIsLoading(true);
try {
@@ -55,6 +92,7 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
}, [
isLoading,
verifyToken,
needCaptcha,
captchaService,
authService,
email,
@@ -66,14 +104,7 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
if (sendingEmail) return;
setSendingEmail(true);
try {
captchaService.revalidate();
await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setAuthData({ state: 'afterSignInSendEmail' });
changeState(prev => ({ ...prev, step: 'signInWithEmail' }));
} catch (err) {
console.error(err);
notify.error({
@@ -82,26 +113,13 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
// TODO(@eyhn): handle error better
}
setSendingEmail(false);
}, [
sendingEmail,
verifyToken,
captchaService,
authService,
email,
challenge,
redirectUrl,
setAuthData,
]);
const sendChangePasswordEmail = useCallback(() => {
setAuthData({ state: 'sendEmail', emailType: 'changePassword' });
}, [setAuthData]);
}, [sendingEmail, changeState]);
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={t['com.affine.brand.affineCloud']()}
subTitle={serverName}
/>
<Wrapper
@@ -128,23 +146,8 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
errorHint={t['com.affine.auth.password.error']()}
onEnter={onSignIn}
/>
<div
className={styles.forgetPasswordButtonRow}
style={{ display: 'none' }} // Not implemented yet.
>
<a
className={styles.linkButton}
onClick={sendChangePasswordEmail}
style={{
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-sm)',
}}
>
{t['com.affine.auth.forget']()}
</a>
</div>
{(verifyToken || !needCaptcha) && (
<div className={styles.sendMagicLinkButtonRow}>
{!isSelfhosted && (
<div className={styles.passwordButtonRow}>
<a
data-testid="send-magic-link-button"
className={styles.linkButton}
@@ -168,8 +171,8 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
</Wrapper>
<BackButton
onClick={useCallback(() => {
setAuthData({ state: 'signIn' });
}, [setAuthData])}
changeState(prev => ({ ...prev, step: 'signIn' }));
}, [changeState])}
/>
</>
);
@@ -0,0 +1,221 @@
import { Button, notify } from '@affine/component';
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
import { OAuth } from '@affine/core/components/affine/auth/oauth';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, ServerService } from '@affine/core/modules/cloud';
import { ServerDeploymentType } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { ArrowRightBigIcon, PublishIcon } from '@blocksuite/icons/rc';
import {
FeatureFlagService,
useLiveData,
useService,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import type { SignInState } from '.';
import * as style from './style.css';
const emailRegex =
/^(?:(?:[^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|((?:[a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
function validateEmail(email: string) {
return emailRegex.test(email);
}
export const SignInStep = ({
state,
changeState,
close,
}: {
state: SignInState;
changeState: Dispatch<SetStateAction<SignInState>>;
close: () => void;
}) => {
const t = useI18n();
const serverService = useService(ServerService);
const serverName = useLiveData(
serverService.server.config$.selector(c => c.serverName)
);
const isSelfhosted = useLiveData(
serverService.server.config$.selector(
c => c.type === ServerDeploymentType.Selfhosted
)
);
const authService = useService(AuthService);
const featureFlagService = useService(FeatureFlagService);
const enableMultipleCloudServers = useLiveData(
featureFlagService.flags.enable_multiple_cloud_servers.$
);
const [isMutating, setIsMutating] = useState(false);
const [email, setEmail] = useState('');
const [isValidEmail, setIsValidEmail] = useState(true);
const loginStatus = useLiveData(authService.session.status$);
useEffect(() => {
if (loginStatus === 'authenticated') {
close();
notify.success({
title: t['com.affine.auth.toast.title.signed-in'](),
message: t['com.affine.auth.toast.message.signed-in'](),
});
}
}, [close, loginStatus, t]);
const onContinue = useAsyncCallback(async () => {
if (!validateEmail(email)) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);
setIsMutating(true);
try {
const { hasPassword, registered, magicLink } =
await authService.checkUserByEmail(email);
if (registered) {
// provider password sign-in if user has by default
// If with payment, onl support email sign in to avoid redirect to affine app
if (hasPassword) {
changeState(prev => ({
...prev,
email,
step: 'signInWithPassword',
}));
} else {
if (magicLink) {
changeState(prev => ({
...prev,
email,
step: 'signInWithEmail',
}));
} else {
notify.error({
title: 'Failed to send email. Please contact the administrator.',
});
}
}
} else {
if (magicLink) {
changeState(prev => ({
...prev,
email,
step: 'signInWithEmail',
}));
} else {
notify.error({
title: 'Failed to send email. Please contact the administrator.',
});
}
}
} catch (err) {
console.error(err);
// TODO(@eyhn): better error handling
notify.error({
title: 'Failed to send email. Please try again.',
});
}
setIsMutating(false);
}, [authService, changeState, email]);
const onAddSelfhosted = useCallback(() => {
changeState(prev => ({
...prev,
step: 'addSelfhosted',
}));
}, [changeState]);
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={serverName}
/>
<OAuth redirectUrl={state.redirectUrl} />
<div className={style.authModalContent}>
<AuthInput
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
onChange={setEmail}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
/>
<Button
style={{ width: '100%' }}
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating}
suffix={<ArrowRightBigIcon />}
suffixStyle={{ width: 20, height: 20, color: cssVar('blue') }}
onClick={onContinue}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
{!isSelfhosted && (
<div className={style.authMessage}>
{/*prettier-ignore*/}
<Trans i18nKey="com.affine.auth.sign.message">
By clicking &quot;Continue with Google/Email&quot; above, you acknowledge that
you agree to AFFiNE&apos;s <a href="https://affine.pro/terms" target="_blank" rel="noreferrer">Terms of Conditions</a> and <a href="https://affine.pro/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>.
</Trans>
</div>
)}
<div className={style.skipDivider}>
<div className={style.skipDividerLine} />
<span className={style.skipDividerText}>or</span>
<div className={style.skipDividerLine} />
</div>
<div className={style.skipSection}>
{!isSelfhosted &&
BUILD_CONFIG.isElectron &&
enableMultipleCloudServers && (
<Button
variant="plain"
className={style.addSelfhostedButton}
prefix={
<PublishIcon className={style.addSelfhostedButtonPrefix} />
}
onClick={onAddSelfhosted}
>
{t['com.affine.auth.sign.add-selfhosted']()}
</Button>
)}
<div className={style.skipText}>
{t['com.affine.mobile.sign-in.skip.hint']()}
</div>
<Button
variant="plain"
onClick={() => close()}
className={style.skipLink}
suffix={<ArrowRightBigIcon className={style.skipLinkIcon} />}
>
{t['com.affine.mobile.sign-in.skip.link']()}
</Button>
</div>
</div>
</>
);
};
@@ -0,0 +1,121 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const authModalContent = style({
marginTop: '30px',
});
export const authMessage = style({
marginTop: '30px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
lineHeight: 1.5,
});
globalStyle(`${authMessage} a`, {
color: cssVar('linkColor'),
});
globalStyle(`${authMessage} .link`, {
cursor: 'pointer',
color: cssVar('linkColor'),
});
export const captchaWrapper = style({
margin: 'auto',
marginBottom: '4px',
textAlign: 'center',
});
export const passwordButtonRow = style({
display: 'flex',
justifyContent: 'space-between',
marginBottom: '30px',
});
export const linkButton = style({
color: cssVar('linkColor'),
background: 'transparent',
borderColor: 'transparent',
fontSize: cssVar('fontXs'),
lineHeight: '22px',
userSelect: 'none',
});
export const resendWrapper = style({
height: 77,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
marginTop: 30,
});
export const sentRow = style({
display: 'flex',
justifyContent: 'center',
gap: '8px',
lineHeight: '22px',
fontSize: cssVar('fontSm'),
});
export const sentMessage = style({
color: cssVar('textPrimaryColor'),
fontWeight: 600,
});
export const resendCountdown = style({
width: 45,
textAlign: 'center',
});
export const addSelfhostedButton = style({
marginTop: 10,
marginLeft: -5,
marginBottom: 16,
color: cssVarV2('text/link'),
});
export const addSelfhostedButtonPrefix = style({
color: cssVarV2('text/link'),
});
export const skipDivider = style({
display: 'flex',
gap: 12,
alignItems: 'center',
height: 20,
marginTop: 12,
marginBottom: 12,
});
export const skipDividerLine = style({
flex: 1,
height: 0,
borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const skipDividerText = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontXs'),
});
export const skipText = style({
color: cssVarV2('text/primary'),
fontSize: cssVar('fontXs'),
fontWeight: 500,
});
export const skipLink = style({
color: cssVarV2('text/link'),
fontSize: cssVar('fontXs'),
});
export const skipLinkIcon = style({
color: cssVarV2('text/link'),
});
export const skipSection = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
});
@@ -1,13 +1,11 @@
import { BrowserWarning, LocalDemoTips } from '@affine/component/affine-banner';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService, type Workspace } from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react';
import { useEnableCloud } from '../components/hooks/affine/use-enable-cloud';
import { AuthService } from '../modules/cloud';
import { authAtom } from './atoms';
import { GlobalDialogService } from '../modules/dialogs';
const minimumChromeVersion = 106;
@@ -69,15 +67,15 @@ export const TopTip = ({
const [showLocalDemoTips, setShowLocalDemoTips] = useState(true);
const confirmEnableCloud = useEnableCloud();
const setAuthModal = useSetAtom(authAtom);
const globalDialogService = useService(GlobalDialogService);
const onLogin = useCallback(() => {
setAuthModal({ openModal: true, state: 'signIn' });
}, [setAuthModal]);
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
if (
!BUILD_CONFIG.isElectron &&
showLocalDemoTips &&
workspace.flavour === WorkspaceFlavour.LOCAL
workspace.flavour === 'local'
) {
return (
<LocalDemoTips
@@ -1,6 +1,5 @@
import { Divider } from '@affine/component/ui/divider';
import { MenuItem } from '@affine/component/ui/menu';
import { authAtom } from '@affine/core/components/atoms';
import { AuthService } from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
@@ -13,7 +12,6 @@ import {
type WorkspaceMetadata,
WorkspacesService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
@@ -23,17 +21,14 @@ import { UserAccountItem } from './user-account';
import { AFFiNEWorkspaceList } from './workspace-list';
export const SignInItem = () => {
const setOpen = useSetAtom(authAtom);
const globalDialogService = useService(GlobalDialogService);
const t = useI18n();
const onClickSignIn = useCallback(() => {
track.$.navigationPanel.workspaceList.requestSignIn();
setOpen(state => ({
...state,
openModal: true,
}));
}, [setOpen]);
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
return (
<MenuItem
@@ -83,14 +78,9 @@ const UserWithWorkspaceListInner = ({
const isAuthenticated = session.status === 'authenticated';
const setOpenSignIn = useSetAtom(authAtom);
const openSignInModal = useCallback(() => {
setOpenSignIn(state => ({
...state,
openModal: true,
}));
}, [setOpenSignIn]);
globalDialogService.open('sign-in', {});
}, [globalDialogService]);
const onNewWorkspace = useCallback(() => {
if (
@@ -12,11 +12,18 @@ export const workspaceListWrapper = style({
flexDirection: 'column',
gap: 2,
});
export const workspaceType = style({
export const workspaceServer = style({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 4,
padding: '0px 12px',
});
export const workspaceServerName = style({
display: 'flex',
alignItems: 'center',
gap: 4,
fontWeight: 500,
fontSize: cssVar('fontXs'),
lineHeight: '20px',
@@ -1,13 +1,26 @@
import { ScrollableContainer } from '@affine/component';
import {
IconButton,
Menu,
MenuItem,
ScrollableContainer,
} from '@affine/component';
import { Divider } from '@affine/component/ui/divider';
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
import { AuthService } from '@affine/core/modules/cloud';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import type { Server } from '@affine/core/modules/cloud';
import { AuthService, ServersService } from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { CloudWorkspaceIcon, LocalWorkspaceIcon } from '@blocksuite/icons/rc';
import {
CloudWorkspaceIcon,
LocalWorkspaceIcon,
MoreHorizontalIcon,
} from '@blocksuite/icons/rc';
import type { WorkspaceMetadata } from '@toeverything/infra';
import {
FrameworkScope,
GlobalContextService,
useLiveData,
useService,
useServiceOptional,
@@ -29,28 +42,97 @@ interface WorkspaceModalProps {
}
const CloudWorkSpaceList = ({
server,
workspaces,
onClickWorkspace,
onClickWorkspaceSetting,
}: Omit<WorkspaceModalProps, 'onNewWorkspace' | 'onAddWorkspace'>) => {
const t = useI18n();
if (workspaces.length === 0) {
return null;
}
onClickEnableCloud,
}: {
server: Server;
workspaces: WorkspaceMetadata[];
onClickWorkspace: (workspaceMetadata: WorkspaceMetadata) => void;
onClickWorkspaceSetting?: (workspaceMetadata: WorkspaceMetadata) => void;
onClickEnableCloud?: (meta: WorkspaceMetadata) => void;
}) => {
const globalContextService = useService(GlobalContextService);
const globalDialogService = useService(GlobalDialogService);
const serverName = useLiveData(server.config$.selector(c => c.serverName));
const authService = useService(AuthService);
const serversService = useService(ServersService);
const account = useLiveData(authService.session.account$);
const accountStatus = useLiveData(authService.session.status$);
const navigateHelper = useNavigateHelper();
const currentWorkspaceFlavour = useLiveData(
globalContextService.globalContext.workspaceFlavour.$
);
const handleDeleteServer = useCallback(() => {
serversService.removeServer(server.id);
if (currentWorkspaceFlavour === server.id) {
const otherWorkspace = workspaces.find(w => w.flavour !== server.id);
if (otherWorkspace) {
navigateHelper.openPage(otherWorkspace.id, 'all');
}
}
}, [
currentWorkspaceFlavour,
navigateHelper,
server.id,
serversService,
workspaces,
]);
const handleSignOut = useAsyncCallback(async () => {
await authService.signOut();
}, [authService]);
const handleSignIn = useAsyncCallback(async () => {
globalDialogService.open('sign-in', {
server: server.baseUrl,
});
}, [globalDialogService, server.baseUrl]);
return (
<div className={styles.workspaceListWrapper}>
<div className={styles.workspaceType}>
<CloudWorkspaceIcon
width={14}
height={14}
className={styles.workspaceTypeIcon}
/>
{t['com.affine.workspaceList.workspaceListType.cloud']()}
<div className={styles.workspaceServer}>
<div className={styles.workspaceServerName}>
<CloudWorkspaceIcon
width={14}
height={14}
className={styles.workspaceTypeIcon}
/>
{serverName}&nbsp;-&nbsp;
{account ? account.email : 'Not signed in'}
</div>
<Menu
items={[
server.id !== 'affine-cloud' && (
<MenuItem key="delete-server" onClick={handleDeleteServer}>
Delete Server
</MenuItem>
),
accountStatus === 'authenticated' && (
<MenuItem key="sign-out" onClick={handleSignOut}>
Sign Out
</MenuItem>
),
accountStatus === 'unauthenticated' && (
<MenuItem key="sign-in" onClick={handleSignIn}>
Sign In
</MenuItem>
),
]}
>
<IconButton icon={<MoreHorizontalIcon />} />
</Menu>
</div>
<WorkspaceList
items={workspaces}
onClick={onClickWorkspace}
onSettingClick={onClickWorkspaceSetting}
onEnableCloudClick={onClickEnableCloud}
/>
</div>
);
@@ -68,13 +150,15 @@ const LocalWorkspaces = ({
}
return (
<div className={styles.workspaceListWrapper}>
<div className={styles.workspaceType}>
<LocalWorkspaceIcon
width={14}
height={14}
className={styles.workspaceTypeIcon}
/>
{t['com.affine.workspaceList.workspaceListType.local']()}
<div className={styles.workspaceServer}>
<div className={styles.workspaceServerName}>
<LocalWorkspaceIcon
width={14}
height={14}
className={styles.workspaceTypeIcon}
/>
{t['com.affine.workspaceList.workspaceListType.local']()}
</div>
</div>
<WorkspaceList
items={workspaces}
@@ -103,15 +187,13 @@ export const AFFiNEWorkspaceList = ({
const confirmEnableCloud = useEnableCloud();
const session = useService(AuthService).session;
const status = useLiveData(session.status$);
const isAuthenticated = status === 'authenticated';
const serversService = useService(ServersService);
const servers = useLiveData(serversService.servers$);
const cloudWorkspaces = useMemo(
() =>
workspaces.filter(
({ flavour }) => flavour === WorkspaceFlavour.AFFINE_CLOUD
({ flavour }) => flavour !== 'local'
) as WorkspaceMetadata[],
[workspaces]
);
@@ -119,7 +201,7 @@ export const AFFiNEWorkspaceList = ({
const localWorkspaces = useMemo(
() =>
workspaces.filter(
({ flavour }) => flavour === WorkspaceFlavour.LOCAL
({ flavour }) => flavour === 'local'
) as WorkspaceMetadata[],
[workspaces]
);
@@ -160,20 +242,23 @@ export const AFFiNEWorkspaceList = ({
className={styles.workspaceListsWrapper}
scrollBarClassName={styles.scrollbar}
>
{isAuthenticated ? (
<div>
<CloudWorkSpaceList
workspaces={cloudWorkspaces}
onClickWorkspace={handleClickWorkspace}
onClickWorkspaceSetting={
showSettingsButton ? onClickWorkspaceSetting : undefined
}
/>
{localWorkspaces.length > 0 && cloudWorkspaces.length > 0 ? (
<div>
{servers.map(server => (
<FrameworkScope key={server.id} scope={server.scope}>
<CloudWorkSpaceList
server={server}
workspaces={cloudWorkspaces.filter(
({ flavour }) => flavour === server.id
)}
onClickWorkspace={handleClickWorkspace}
onClickWorkspaceSetting={
showSettingsButton ? onClickWorkspaceSetting : undefined
}
/>
<Divider size="thinner" />
) : null}
</div>
) : null}
</FrameworkScope>
))}
</div>
<LocalWorkspaces
workspaces={localWorkspaces}
onClickWorkspace={handleClickWorkspace}
@@ -5,7 +5,6 @@ import { useSystemOnline } from '@affine/core/components/hooks/use-system-online
import { useWorkspace } from '@affine/core/components/hooks/use-workspace';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { WorkspaceFlavour } from '@affine/env/workspace';
import {
ArrowDownSmallIcon,
CloudWorkspaceIcon,
@@ -97,7 +96,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
let content;
// TODO(@eyhn): add i18n
if (workspace.flavour === WorkspaceFlavour.LOCAL) {
if (workspace.flavour === 'local') {
if (!BUILD_CONFIG.isElectron) {
content = 'This is a local demo workspace.';
} else {
@@ -132,7 +131,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
return {
message: content,
icon:
workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
workspace.flavour !== 'local' ? (
!isOnline ? (
<OfflineStatus />
) : (
@@ -143,7 +142,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
),
progress,
active:
workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD &&
workspace.flavour !== 'local' &&
((syncing && progress !== undefined) || engineState.retrying), // active if syncing or retrying,
};
};
@@ -173,7 +172,7 @@ const WorkspaceSyncInfo = ({
workspaceProfile: WorkspaceProfileInfo;
}) => {
const syncStatus = useSyncEngineSyncProgress(workspaceMetadata);
const isCloud = workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const isCloud = workspaceMetadata.flavour !== 'local';
const { paused, pause } = usePauseAnimation();
// to make sure that animation will play first time
@@ -315,8 +314,7 @@ export const WorkspaceCard = forwardRef<
)}
</div>
<div className={styles.showOnCardHover}>
{onClickEnableCloud &&
workspaceMetadata.flavour === WorkspaceFlavour.LOCAL ? (
{onClickEnableCloud && workspaceMetadata.flavour === 'local' ? (
<Button
className={styles.enableCloudButton}
onClick={onEnableCloud}