refactor(server): auth (#5895)

Remove `next-auth` and implement our own Authorization/Authentication system from scratch.

## Server

- [x] tokens
  - [x] function
  - [x] encryption

- [x] AuthController
  - [x] /api/auth/sign-in
  - [x] /api/auth/sign-out
  - [x] /api/auth/session
  - [x] /api/auth/session (WE SUPPORT MULTI-ACCOUNT!)

- [x] OAuthPlugin
  - [x] OAuthController
  - [x] /oauth/login
  - [x] /oauth/callback
  - [x] Providers
    - [x] Google
    - [x] GitHub

## Client

- [x] useSession
- [x] cloudSignIn
- [x] cloudSignOut

## NOTE:

Tests will be adding in the future
This commit is contained in:
liuyi
2024-03-12 10:00:09 +00:00
parent af49e8cc41
commit fb3a0e7b8f
148 changed files with 3407 additions and 2851 deletions

View File

@@ -1,4 +0,0 @@
import { atom } from 'jotai';
import type { SessionContextValue } from 'next-auth/react';
export const sessionAtom = atom<SessionContextValue<true> | null>(null);

View File

@@ -24,7 +24,7 @@ export type AuthProps = {
setAuthEmail: (state: AuthProps['email']) => void;
setEmailType: (state: AuthProps['emailType']) => void;
email: string;
emailType: 'setPassword' | 'changePassword' | 'changeEmail';
emailType: 'setPassword' | 'changePassword' | 'changeEmail' | 'verifyEmail';
onSignedIn?: () => void;
};
@@ -59,8 +59,10 @@ export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
emailType,
}) => {
const onSignedIn = useCallback(() => {
setAuthState('signIn');
setAuthEmail('');
setOpen(false);
}, [setOpen]);
}, [setAuthState, setAuthEmail, setOpen]);
return (
<AuthModalBase open={open} setOpen={setOpen}>

View File

@@ -0,0 +1,66 @@
import { Button } from '@affine/component/ui/button';
import {
useOAuthProviders,
useServerFeatures,
} from '@affine/core/hooks/affine/use-server-config';
import { OAuthProviderType } from '@affine/graphql';
import { GithubIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
import { type ReactElement, useCallback } from 'react';
import { useAuth } from './use-auth';
const OAuthProviderMap: Record<
OAuthProviderType,
{
icon: ReactElement;
}
> = {
[OAuthProviderType.Google]: {
icon: <GoogleDuotoneIcon />,
},
[OAuthProviderType.GitHub]: {
icon: <GithubIcon />,
},
};
export function OAuth() {
const { oauth } = useServerFeatures();
if (!oauth) {
return null;
}
return <OAuthProviders />;
}
function OAuthProviders() {
const providers = useOAuthProviders();
return providers.map(provider => (
<OAuthProvider key={provider} provider={provider} />
));
}
function OAuthProvider({ provider }: { provider: OAuthProviderType }) {
const { icon } = OAuthProviderMap[provider];
const { oauthSignIn } = useAuth();
const onClick = useCallback(() => {
oauthSignIn(provider);
}, [provider, oauthSignIn]);
return (
<Button
key={provider}
type="primary"
block
size="extraLarge"
style={{ marginTop: 30 }}
icon={icon}
onClick={onClick}
>
Continue with {provider}
</Button>
);
}

View File

@@ -12,6 +12,7 @@ import {
sendChangeEmailMutation,
sendChangePasswordEmailMutation,
sendSetPasswordEmailMutation,
sendVerifyEmailMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useSetAtom } from 'jotai/react';
@@ -29,7 +30,9 @@ const useEmailTitle = (emailType: AuthPanelProps['emailType']) => {
case 'changePassword':
return t['com.affine.auth.reset.password']();
case 'changeEmail':
return t['com.affine.settings.email.action']();
return t['com.affine.settings.email.action.change']();
case 'verifyEmail':
return t['com.affine.settings.email.action.verify']();
}
};
const useContent = (emailType: AuthPanelProps['emailType'], email: string) => {
@@ -41,7 +44,8 @@ const useContent = (emailType: AuthPanelProps['emailType'], email: string) => {
case 'changePassword':
return t['com.affine.auth.reset.password.message']();
case 'changeEmail':
return t['com.affine.auth.change.email.message']({
case 'verifyEmail':
return t['com.affine.auth.verify.email.message']({
email,
});
}
@@ -56,7 +60,8 @@ const useNotificationHint = (emailType: AuthPanelProps['emailType']) => {
case 'changePassword':
return t['com.affine.auth.sent.change.password.hint']();
case 'changeEmail':
return t['com.affine.auth.sent.change.email.hint']();
case 'verifyEmail':
return t['com.affine.auth.sent.verify.email.hint']();
}
};
const useButtonContent = (emailType: AuthPanelProps['emailType']) => {
@@ -68,7 +73,8 @@ const useButtonContent = (emailType: AuthPanelProps['emailType']) => {
case 'changePassword':
return t['com.affine.auth.send.reset.password.link']();
case 'changeEmail':
return t['com.affine.auth.send.change.email.link']();
case 'verifyEmail':
return t['com.affine.auth.send.verify.email.hint']();
}
};
@@ -87,12 +93,17 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
useMutation({
mutation: sendChangeEmailMutation,
});
const { trigger: sendVerifyEmail, isMutating: isVerifyEmailMutation } =
useMutation({
mutation: sendVerifyEmailMutation,
});
return {
loading:
isChangePasswordMutating ||
isSetPasswordMutating ||
isChangeEmailMutating,
isChangeEmailMutating ||
isVerifyEmailMutation,
sendEmail: useCallback(
(email: string) => {
let trigger: (args: {
@@ -113,6 +124,10 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
trigger = sendChangeEmail;
callbackUrl = 'changeEmail';
break;
case 'verifyEmail':
trigger = sendVerifyEmail;
callbackUrl = 'verify-email';
break;
}
// TODO: add error handler
return trigger({
@@ -127,6 +142,7 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
sendChangeEmail,
sendChangePasswordEmail,
sendSetPasswordEmail,
sendVerifyEmail,
]
),
};

View File

@@ -5,10 +5,9 @@ import {
ModalHeader,
} from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useSession } from '@affine/core/hooks/affine/use-current-user';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
@@ -25,7 +24,7 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
onSignedIn,
}) => {
const t = useAFFiNEI18N();
const { update } = useSession();
const { reload } = useSession();
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState(false);
@@ -39,7 +38,6 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
const onSignIn = useAsyncCallback(async () => {
const res = await signInCloud('credentials', {
redirect: false,
email,
password,
}).catch(console.error);
@@ -48,9 +46,9 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
return setPasswordError(true);
}
await update();
await reload();
onSignedIn?.();
}, [email, password, onSignedIn, update]);
}, [email, password, onSignedIn, reload]);
const sendMagicLink = useAsyncCallback(async () => {
if (allowSendEmail && verifyToken && !sendingEmail) {

View File

@@ -12,7 +12,7 @@ import {
} from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
import { ArrowDownBigIcon } from '@blocksuite/icons';
import { type FC, useState } from 'react';
import { useCallback } from 'react';
@@ -20,6 +20,7 @@ import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-s
import { useMutation } from '../../../hooks/use-mutation';
import { emailRegex } from '../../../utils/email-regex';
import type { AuthPanelProps } from './index';
import { OAuth } from './oauth';
import * as style from './style.css';
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
@@ -46,7 +47,6 @@ export const SignIn: FC<AuthPanelProps> = ({
allowSendEmail,
signIn,
signUp,
signInWithGoogle,
} = useAuth();
const { trigger: verifyUser, isMutating } = useMutation({
@@ -59,6 +59,10 @@ export const SignIn: FC<AuthPanelProps> = ({
}
const onContinue = useAsyncCallback(async () => {
if (!allowSendEmail) {
return;
}
if (!validateEmail(email)) {
setIsValidEmail(false);
return;
@@ -99,13 +103,14 @@ export const SignIn: FC<AuthPanelProps> = ({
const res = await signUp(email, verifyToken, challenge);
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
return setAuthState('noAccess');
} else if (!res || res.status >= 400 || res.error) {
} else if (!res || res.status >= 400) {
return;
}
setAuthState('afterSignUpSendEmail');
}
}
}, [
allowSendEmail,
subscriptionData,
challenge,
email,
@@ -124,20 +129,7 @@ export const SignIn: FC<AuthPanelProps> = ({
subTitle={t['com.affine.brand.affineCloud']()}
/>
<Button
type="primary"
block
size="extraLarge"
style={{
marginTop: 30,
}}
icon={<GoogleDuotoneIcon />}
onClick={useCallback(() => {
signInWithGoogle();
}, [signInWithGoogle])}
>
{t['Continue with Google']()}
</Button>
<OAuth />
<div className={style.authModalContent}>
<AuthInput

View File

@@ -1,7 +1,7 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import type { Notification } from '@affine/component/notification-center/index.jotai';
import type { OAuthProviderType } from '@affine/graphql';
import { atom, useAtom, useSetAtom } from 'jotai';
import { type SignInResponse } from 'next-auth/react';
import { useCallback } from 'react';
import { signInCloud } from '../../../utils/cloud-utils';
@@ -11,10 +11,10 @@ const COUNT_DOWN_TIME = 60;
export const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
function handleSendEmailError(
res: SignInResponse | undefined | void,
res: Response | undefined | void,
pushNotification: (notification: Notification) => void
) {
if (res?.error) {
if (!res?.ok) {
pushNotification({
title: 'Send email error',
message: 'Please back to home and try again',
@@ -64,8 +64,13 @@ export const useAuth = () => {
const [authStore, setAuthStore] = useAtom(authStoreAtom);
const startResendCountDown = useSetAtom(countDownAtom);
const signIn = useCallback(
async (email: string, verifyToken: string, challenge?: string) => {
const sendEmailMagicLink = useCallback(
async (
signUp: boolean,
email: string,
verifyToken: string,
challenge?: string
) => {
setAuthStore(prev => {
return {
...prev,
@@ -76,18 +81,19 @@ export const useAuth = () => {
const res = await signInCloud(
'email',
{
email: email,
callbackUrl: subscriptionData
? subscriptionData.getRedirectUrl(false)
: '/auth/signIn',
redirect: false,
email,
},
challenge
? {
challenge,
token: verifyToken,
}
: { token: verifyToken }
{
...(challenge
? {
challenge,
token: verifyToken,
}
: { token: verifyToken }),
callbackUrl: subscriptionData
? subscriptionData.getRedirectUrl(signUp)
: '/auth/signIn',
}
).catch(console.error);
handleSendEmailError(res, pushNotification);
@@ -107,47 +113,24 @@ export const useAuth = () => {
const signUp = useCallback(
async (email: string, verifyToken: string, challenge?: string) => {
setAuthStore(prev => {
return {
...prev,
isMutating: true,
};
});
const res = await signInCloud(
'email',
{
email: email,
callbackUrl: subscriptionData
? subscriptionData.getRedirectUrl(true)
: '/auth/signUp',
redirect: false,
},
challenge
? {
challenge,
token: verifyToken,
}
: { token: verifyToken }
).catch(console.error);
handleSendEmailError(res, pushNotification);
setAuthStore({
isMutating: false,
allowSendEmail: false,
resendCountDown: COUNT_DOWN_TIME,
});
startResendCountDown();
return res;
return sendEmailMagicLink(true, email, verifyToken, challenge).catch(
console.error
);
},
[pushNotification, setAuthStore, startResendCountDown, subscriptionData]
[sendEmailMagicLink]
);
const signInWithGoogle = useCallback(() => {
signInCloud('google').catch(console.error);
const signIn = useCallback(
async (email: string, verifyToken: string, challenge?: string) => {
return sendEmailMagicLink(false, email, verifyToken, challenge).catch(
console.error
);
},
[sendEmailMagicLink]
);
const oauthSignIn = useCallback((provider: OAuthProviderType) => {
signInCloud(provider).catch(console.error);
}, []);
const resetCountDown = useCallback(() => {
@@ -165,6 +148,6 @@ export const useAuth = () => {
isMutating: authStore.isMutating,
signUp,
signIn,
signInWithGoogle,
oauthSignIn,
};
};

View File

@@ -3,21 +3,21 @@ import { useLiveData } from '@toeverything/infra/livedata';
import { Suspense, useEffect } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
import { useSession } from '../../../hooks/affine/use-current-user';
import { CurrentWorkspaceService } from '../../../modules/workspace/current-workspace';
const SyncAwarenessInnerLoggedIn = () => {
const currentUser = useCurrentUser();
const { user } = useSession();
const currentWorkspace = useLiveData(
useService(CurrentWorkspaceService).currentWorkspace
);
useEffect(() => {
if (currentUser && currentWorkspace) {
if (user && currentWorkspace) {
currentWorkspace.blockSuiteWorkspace.awarenessStore.awareness.setLocalStateField(
'user',
{
name: currentUser.name,
name: user.name,
// todo: add avatar?
}
);
@@ -30,7 +30,7 @@ const SyncAwarenessInnerLoggedIn = () => {
};
}
return;
}, [currentUser, currentWorkspace]);
}, [user, currentWorkspace]);
return null;
};

View File

@@ -13,6 +13,7 @@ import {
allBlobSizesQuery,
removeAvatarMutation,
SubscriptionPlan,
updateUserProfileMutation,
uploadAvatarMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -58,11 +59,10 @@ export const UserAvatar = () => {
async (file: File) => {
try {
const reducedFile = await validateAndReduceImage(file);
await avatarTrigger({
const data = await avatarTrigger({
avatar: reducedFile, // Pass the reducedFile directly to the avatarTrigger
});
// XXX: This is a hack to force the user to update, since next-auth can not only use update function without params
await user.update({ name: user.name });
user.update({ avatarUrl: data.uploadAvatar.avatarUrl });
pushNotification({
title: 'Update user avatar success',
type: 'success',
@@ -82,8 +82,7 @@ export const UserAvatar = () => {
async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
await removeAvatarTrigger();
// XXX: This is a hack to force the user to update, since next-auth can not only use update function without params
user.update({ name: user.name }).catch(console.error);
user.update({ avatarUrl: null });
},
[removeAvatarTrigger, user]
);
@@ -97,9 +96,9 @@ export const UserAvatar = () => {
<Avatar
size={56}
name={user.name}
url={user.image}
url={user.avatarUrl}
hoverIcon={<CameraIcon />}
onRemove={user.image ? handleRemoveUserAvatar : undefined}
onRemove={user.avatarUrl ? handleRemoveUserAvatar : undefined}
avatarTooltipOptions={{ content: t['Click to replace photo']() }}
removeTooltipOptions={{ content: t['Remove photo']() }}
data-testid="user-setting-avatar"
@@ -115,14 +114,30 @@ export const AvatarAndName = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const [input, setInput] = useState<string>(user.name);
const pushNotification = useSetAtom(pushNotificationAtom);
const { trigger: updateProfile } = useMutation({
mutation: updateUserProfileMutation,
});
const allowUpdate = !!input && input !== user.name;
const handleUpdateUserName = useCallback(() => {
const handleUpdateUserName = useAsyncCallback(async () => {
if (!allowUpdate) {
return;
}
user.update({ name: input }).catch(console.error);
}, [allowUpdate, input, user]);
try {
const data = await updateProfile({
input: { name: input },
});
user.update({ name: data.updateProfile.name });
} catch (e) {
pushNotification({
title: 'Failed to update user name.',
message: String(e),
type: 'error',
});
}
}, [allowUpdate, input, user, updateProfile, pushNotification]);
return (
<SettingRow
@@ -222,9 +237,9 @@ export const AccountSetting: FC = () => {
openModal: true,
state: 'sendEmail',
email: user.email,
emailType: 'changeEmail',
emailType: user.emailVerified ? 'changeEmail' : 'verifyEmail',
});
}, [setAuthModal, user.email]);
}, [setAuthModal, user.email, user.emailVerified]);
const onPasswordButtonClick = useCallback(() => {
setAuthModal({
@@ -249,7 +264,9 @@ export const AccountSetting: FC = () => {
<AvatarAndName />
<SettingRow name={t['com.affine.settings.email']()} desc={user.email}>
<Button onClick={onChangeEmail} className={styles.button}>
{t['com.affine.settings.email.action']()}
{user.emailVerified
? t['com.affine.settings.email.action.change']()
: t['com.affine.settings.email.action.verify']()}
</Button>
</SettingRow>
<SettingRow

View File

@@ -49,7 +49,12 @@ export const UserInfo = ({
})}
onClick={onAccountSettingClick}
>
<Avatar size={28} name={user.name} url={user.image} className="avatar" />
<Avatar
size={28}
name={user.name}
url={user.avatarUrl}
className="avatar"
/>
<div className="content">
<div className="name-container">

View File

@@ -26,7 +26,7 @@ const UserInfo = () => {
<Avatar
size={28}
name={user.name}
url={user.image}
url={user.avatarUrl}
className={styles.avatar}
/>
@@ -51,7 +51,7 @@ export const PublishPageUserAvatar = () => {
const location = useLocation();
const handleSignOut = useAsyncCallback(async () => {
await signOutCloud({ callbackUrl: location.pathname });
await signOutCloud(location.pathname);
}, [location.pathname]);
const menuItem = useMemo(() => {
@@ -84,7 +84,7 @@ export const PublishPageUserAvatar = () => {
}}
>
<div className={styles.iconWrapper} data-testid="share-page-user-avatar">
<Avatar size={24} url={user.image} name={user.name} />
<Avatar size={24} url={user.avatarUrl} name={user.name} />
</div>
</Menu>
);

View File

@@ -25,7 +25,7 @@ const SignInButton = () => {
<StyledSignInButton
data-testid="sign-in-button"
onClick={useCallback(() => {
signInCloud().catch(console.error);
signInCloud('email').catch(console.error);
}, [])}
>
<div className="circle">

View File

@@ -1,5 +1,6 @@
import { Divider } from '@affine/component/ui/divider';
import { MenuItem } from '@affine/component/ui/menu';
import { useSession } from '@affine/core/hooks/affine/use-current-user';
import { Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Logo1Icon } from '@blocksuite/icons';
@@ -7,9 +8,7 @@ import { WorkspaceManager } from '@toeverything/infra';
import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata';
import { useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import {
authAtom,
@@ -68,9 +67,9 @@ export const UserWithWorkspaceList = ({
}: {
onEventEnd?: () => void;
}) => {
const { data: session, status } = useSession();
const { user, status } = useSession();
const isAuthenticated = useMemo(() => status === 'authenticated', [status]);
const isAuthenticated = status === 'authenticated';
const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom);
const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom);
@@ -124,7 +123,7 @@ export const UserWithWorkspaceList = ({
<div className={styles.workspaceListWrapper}>
{isAuthenticated ? (
<UserAccountItem
email={session?.user.email ?? 'Unknown User'}
email={user?.email ?? 'Unknown User'}
onEventEnd={onEventEnd}
/>
) : (

View File

@@ -1,6 +1,7 @@
import { ScrollableContainer } from '@affine/component';
import { Divider } from '@affine/component/ui/divider';
import { WorkspaceList } from '@affine/component/workspace-list';
import { useSession } from '@affine/core/hooks/affine/use-current-user';
import {
useWorkspaceAvatar,
useWorkspaceName,
@@ -12,8 +13,6 @@ import { WorkspaceManager, type WorkspaceMetadata } from '@toeverything/infra';
import { useService } from '@toeverything/infra/di';
import { useLiveData } from '@toeverything/infra/livedata';
import { useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import { useCallback, useMemo } from 'react';
import {
@@ -119,10 +118,9 @@ export const AFFiNEWorkspaceList = ({
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
// TODO: AFFiNE Cloud support
const { status } = useSession();
const isAuthenticated = useMemo(() => status === 'authenticated', [status]);
const isAuthenticated = status === 'authenticated';
const cloudWorkspaces = useMemo(
() =>

View File

@@ -1,10 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import { useSession } from './use-current-user';
export function useCurrentLoginStatus():
| 'authenticated'
| 'unauthenticated'
| 'loading' {
export function useCurrentLoginStatus() {
const session = useSession();
return session.status;
}

View File

@@ -1,42 +1,83 @@
import { type User } from '@affine/component/auth-components';
import type { DefaultSession, Session } from 'next-auth';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { getSession, useSession } from 'next-auth/react';
import { useEffect, useMemo, useReducer } from 'react';
import { DebugLogger } from '@affine/debug';
import { getBaseUrl } from '@affine/graphql';
import { useMemo, useReducer } from 'react';
import useSWR from 'swr';
import { SessionFetchErrorRightAfterLoginOrSignUp } from '../../unexpected-application-state/errors';
import { useAsyncCallback } from '../affine-async-hooks';
export type CheckedUser = User & {
const logger = new DebugLogger('auth');
interface User {
id: string;
email: string;
name: string;
hasPassword: boolean;
update: ReturnType<typeof useSession>['update'];
avatarUrl: string | null;
emailVerified: string | null;
}
export interface Session {
user?: User | null;
status: 'authenticated' | 'unauthenticated' | 'loading';
reload: () => Promise<void>;
}
export type CheckedUser = Session['user'] & {
update: (changes?: Partial<User>) => void;
};
declare module 'next-auth' {
interface Session {
user: {
name: string;
email: string;
id: string;
hasPassword: boolean;
} & Omit<NonNullable<DefaultSession['user']>, 'name' | 'email'>;
export async function getSession(
url: string = getBaseUrl() + '/api/auth/session'
) {
try {
const res = await fetch(url);
if (res.ok) {
return (await res.json()) as { user?: User | null };
}
logger.error('Failed to fetch session', res.statusText);
return { user: null };
} catch (e) {
logger.error('Failed to fetch session', e);
return { user: null };
}
}
export function useSession(): Session {
const { data, mutate, isLoading } = useSWR('session', () => getSession());
return {
user: data?.user,
status: isLoading
? 'loading'
: data?.user
? 'authenticated'
: 'unauthenticated',
reload: async () => {
return mutate().then(e => {
console.error(e);
});
},
};
}
type UpdateSessionAction =
| {
type: 'update';
payload: Session;
payload?: Partial<User>;
}
| {
type: 'fetchError';
payload: null;
};
function updateSessionReducer(prevState: Session, action: UpdateSessionAction) {
function updateSessionReducer(prevState: User, action: UpdateSessionAction) {
const { type, payload } = action;
switch (type) {
case 'update':
return payload;
return { ...prevState, ...payload };
case 'fetchError':
return prevState;
}
@@ -49,11 +90,11 @@ function updateSessionReducer(prevState: Session, action: UpdateSessionAction) {
* If network error or API response error, it will use the cached value.
*/
export function useCurrentUser(): CheckedUser {
const { data, update } = useSession();
const session = useSession();
const [session, dispatcher] = useReducer(
const [user, dispatcher] = useReducer(
updateSessionReducer,
data,
session.user,
firstSession => {
if (!firstSession) {
// barely possible.
@@ -64,10 +105,10 @@ export function useCurrentUser(): CheckedUser {
() => {
getSession()
.then(session => {
if (session) {
if (session.user) {
dispatcher({
type: 'update',
payload: session,
payload: session.user,
});
}
})
@@ -77,35 +118,30 @@ export function useCurrentUser(): CheckedUser {
}
);
}
return firstSession;
}
);
useEffect(() => {
if (data) {
const update = useAsyncCallback(
async (changes?: Partial<User>) => {
dispatcher({
type: 'update',
payload: data,
payload: changes,
});
} else {
dispatcher({
type: 'fetchError',
payload: null,
});
}
}, [data, update]);
const user = session.user;
await session.reload();
},
[dispatcher, session]
);
return useMemo(() => {
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
hasPassword: user?.hasPassword ?? false,
return useMemo(
() => ({
...user,
update,
};
// spread the user object to make sure the hook will not be re-rendered when user ref changed but the properties not.
}, [user.id, user.name, user.email, user.image, user.hasPassword, update]);
}),
// only list the things will change as deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[user.id, user.avatarUrl, user.name, update]
);
}

View File

@@ -1,11 +1,12 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import { useMemo } from 'react';
import { useSession } from './use-current-user';
export const useDeleteCollectionInfo = () => {
const user = useSession().data?.user;
const { user } = useSession();
return useMemo(
() => (user ? { userName: user.name ?? '', userId: user.id } : null),
() => (user ? { userName: user.name, userId: user.id } : null),
[user]
);
};

View File

@@ -1,5 +1,5 @@
import type { ServerFeature } from '@affine/graphql';
import { serverConfigQuery } from '@affine/graphql';
import { oauthProvidersQuery, serverConfigQuery } from '@affine/graphql';
import type { BareFetcher, Middleware } from 'swr';
import { useQueryImmutable } from '../use-query';
@@ -44,6 +44,21 @@ export const useServerFeatures = (): ServerFeatureRecord => {
}, {} as ServerFeatureRecord);
};
export const useOAuthProviders = () => {
const { data, error } = useQueryImmutable(
{ query: oauthProvidersQuery },
{
use: [errorHandler],
}
);
if (error || !data) {
return [];
}
return data.serverConfig.oauthProviders;
};
export const useServerBaseUrl = () => {
const config = useServerConfig();

View File

@@ -1,7 +1,6 @@
import { NotFoundPage } from '@affine/component/not-found-page';
import { useSession } from '@affine/core/hooks/affine/use-current-user';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import type { ReactElement } from 'react';
import { useCallback, useState } from 'react';
@@ -10,7 +9,7 @@ import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { signOutCloud } from '../utils/cloud-utils';
export const PageNotFound = (): ReactElement => {
const { data: session } = useSession();
const { user } = useSession();
const { jumpToIndex } = useNavigateHelper();
const [open, setOpen] = useState(false);
@@ -25,22 +24,12 @@ export const PageNotFound = (): ReactElement => {
const onConfirmSignOut = useAsyncCallback(async () => {
setOpen(false);
await signOutCloud({
callbackUrl: '/signIn',
});
await signOutCloud('/signIn');
}, [setOpen]);
return (
<>
<NotFoundPage
user={
session?.user
? {
name: session.user.name || '',
email: session.user.email || '',
avatar: session.user.image || '',
}
: null
}
user={user}
onBack={handleBackButtonClick}
onSignOut={handleOpenSignOutModal}
/>

View File

@@ -12,6 +12,7 @@ import {
changeEmailMutation,
changePasswordMutation,
sendVerifyChangeEmailMutation,
verifyEmailMutation,
} from '@affine/graphql';
import { fetcher } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -42,6 +43,7 @@ const authTypeSchema = z.enum([
'changeEmail',
'confirm-change-email',
'subscription-redirect',
'verify-email',
]);
export const AuthPage = (): ReactElement | null => {
@@ -73,12 +75,10 @@ export const AuthPage = (): ReactElement | null => {
// FIXME: There is not notification
if (res?.sendVerifyChangeEmail) {
pushNotification({
title: t['com.affine.auth.sent.change.email.hint'](),
title: t['com.affine.auth.sent.verify.email.hint'](),
type: 'success',
});
}
if (!res?.sendVerifyChangeEmail) {
} else {
pushNotification({
title: t['com.affine.auth.sent.change.email.fail'](),
type: 'error',
@@ -156,6 +156,9 @@ export const AuthPage = (): ReactElement | null => {
case 'subscription-redirect': {
return <SubscriptionRedirect />;
}
case 'verify-email': {
return <ConfirmChangeEmail onOpenAffine={onOpenAffine} />;
}
}
return null;
};
@@ -171,20 +174,37 @@ export const loader: LoaderFunction = async args => {
if (args.params.authType === 'confirm-change-email') {
const url = new URL(args.request.url);
const searchParams = url.searchParams;
const token = searchParams.get('token');
const token = searchParams.get('token') ?? '';
const email = decodeURIComponent(searchParams.get('email') ?? '');
const res = await fetcher({
query: changeEmailMutation,
variables: {
token: token || '',
token: token,
email: email,
},
}).catch(console.error);
// TODO: Add error handling
if (!res?.changeEmail) {
return redirect('/expired');
}
} else if (args.params.authType === 'verify-email') {
const url = new URL(args.request.url);
const searchParams = url.searchParams;
const token = searchParams.get('token') ?? '';
const res = await fetcher({
query: verifyEmailMutation,
variables: {
token: token,
},
}).catch(console.error);
if (!res?.verifyEmail) {
return redirect('/expired');
}
}
return null;
};
export const Component = () => {
const loginStatus = useCurrentLoginStatus();
const { jumpToExpired } = useNavigateHelper();

View File

@@ -1,34 +1,43 @@
import { getSession } from 'next-auth/react';
import { OAuthProviderType } from '@affine/graphql';
import { type LoaderFunction } from 'react-router-dom';
import { z } from 'zod';
import { getSession } from '../hooks/affine/use-current-user';
import { signInCloud, signOutCloud } from '../utils/cloud-utils';
const supportedProvider = z.enum(['google']);
const supportedProvider = z.enum([
'google',
...Object.values(OAuthProviderType),
]);
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
const provider = searchParams.get('provider');
const callback_url = searchParams.get('callback_url');
if (!callback_url) {
const redirectUri =
searchParams.get('redirect_uri') ??
/* backward compatibility */ searchParams.get('callback_url');
if (!redirectUri) {
return null;
}
const session = await getSession();
if (session) {
if (session.user) {
// already signed in, need to sign out first
await signOutCloud({
callbackUrl: request.url, // retry
});
await signOutCloud(request.url);
}
const maybeProvider = supportedProvider.safeParse(provider);
if (maybeProvider.success) {
const provider = maybeProvider.data;
await signInCloud(provider, {
callbackUrl: callback_url,
let provider = maybeProvider.data;
// BACKWARD COMPATIBILITY
if (provider === 'google') {
provider = OAuthProviderType.Google;
}
await signInCloud(provider, undefined, {
redirectUri,
});
}
return null;

View File

@@ -216,9 +216,7 @@ export const SignOutConfirmModal = () => {
const onConfirm = useAsyncCallback(async () => {
setOpen(false);
await signOutCloud({
redirect: false,
});
await signOutCloud();
// if current workspace is affine cloud, switch to local workspace
if (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD) {

View File

@@ -1,11 +1,10 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { useSession } from '@affine/core/hooks/affine/use-current-user';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { affine } from '@affine/electron-api';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace-impl';
import { useAtom, useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { SessionProvider, useSession } from 'next-auth/react';
import { useSetAtom } from 'jotai';
import {
type PropsWithChildren,
startTransition,
@@ -13,13 +12,11 @@ import {
useRef,
} from 'react';
import { sessionAtom } from '../atoms/cloud-user';
import { useOnceSignedInEvents } from '../atoms/event';
const SessionDefence = (props: PropsWithChildren) => {
export const CloudSessionProvider = (props: PropsWithChildren) => {
const session = useSession();
const prevSession = useRef<ReturnType<typeof useSession>>();
const [sessionInAtom, setSession] = useAtom(sessionAtom);
const pushNotification = useSetAtom(pushNotificationAtom);
const onceSignedInEvents = useOnceSignedInEvents();
const t = useAFFiNEI18N();
@@ -32,10 +29,6 @@ const SessionDefence = (props: PropsWithChildren) => {
}, [onceSignedInEvents]);
useEffect(() => {
if (sessionInAtom !== session && session.status === 'authenticated') {
setSession(session);
}
if (prevSession.current !== session && session.status !== 'loading') {
// unauthenticated -> authenticated
if (
@@ -55,22 +48,7 @@ const SessionDefence = (props: PropsWithChildren) => {
}
prevSession.current = session;
}
}, [
session,
sessionInAtom,
prevSession,
setSession,
pushNotification,
refreshAfterSignedInEvents,
t,
]);
}, [session, prevSession, pushNotification, refreshAfterSignedInEvents, t]);
return props.children;
};
export const CloudSessionProvider = ({ children }: PropsWithChildren) => {
return (
<SessionProvider refetchOnWindowFocus>
<SessionDefence>{children}</SessionDefence>
</SessionProvider>
);
};

View File

@@ -1,12 +1,12 @@
import {
generateRandUTF16Chars,
getBaseUrl,
OAuthProviderType,
SPAN_ID_BYTES,
TRACE_ID_BYTES,
traceReporter,
} from '@affine/graphql';
import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace-impl';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { signIn, signOut } from 'next-auth/react';
type TraceParams = {
startTime: string;
@@ -43,62 +43,95 @@ function onRejectHandleTrace<T>(
return Promise.reject(res);
}
export const signInCloud: typeof signIn = async (provider, ...rest) => {
type Providers = 'credentials' | 'email' | OAuthProviderType;
export const signInCloud = async (
provider: Providers,
credentials?: { email: string; password?: string },
searchParams: Record<string, any> = {}
): Promise<Response | undefined> => {
const traceParams = genTraceParams();
if (environment.isDesktop) {
if (provider === 'google') {
if (provider === 'credentials' || provider === 'email') {
if (!credentials) {
throw new Error('Invalid Credentials');
}
return signIn(credentials, searchParams)
.then(res => onResolveHandleTrace(res, traceParams))
.catch(err => onRejectHandleTrace(err, traceParams));
} else if (OAuthProviderType[provider]) {
if (environment.isDesktop) {
open(
`${
runtimeConfig.serverUrlPrefix
}/desktop-signin?provider=google&callback_url=${buildCallbackUrl(
}/desktop-signin?provider=${provider}&redirect_uri=${buildRedirectUri(
'/open-app/signin-redirect'
)}`,
'_target'
);
return;
} else {
const [options, ...tail] = rest;
const callbackUrl =
runtimeConfig.serverUrlPrefix +
(provider === 'email'
? '/open-app/signin-redirect'
: location.pathname);
return signIn(
provider,
{
...options,
callbackUrl: buildCallbackUrl(callbackUrl),
},
...tail
)
.then(res => onResolveHandleTrace(res, traceParams))
.catch(err => onRejectHandleTrace(err, traceParams));
location.href = `${
runtimeConfig.serverUrlPrefix
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
searchParams.redirectUri ?? location.pathname
)}`;
}
return;
} else {
return signIn(provider, ...rest)
.then(res => onResolveHandleTrace(res, traceParams))
.catch(err => onRejectHandleTrace(err, traceParams));
throw new Error('Invalid Provider');
}
};
export const signOutCloud: typeof signOut = async options => {
async function signIn(
credential: { email: string; password?: string },
searchParams: Record<string, any> = {}
) {
const url = new URL(getBaseUrl() + '/api/auth/sign-in');
for (const key in searchParams) {
url.searchParams.set(key, searchParams[key]);
}
const redirectUri =
runtimeConfig.serverUrlPrefix +
(environment.isDesktop
? buildRedirectUri('/open-app/signin-redirect')
: location.pathname);
url.searchParams.set('redirect_uri', redirectUri);
return fetch(url.toString(), {
method: 'POST',
body: JSON.stringify(credential),
headers: {
'content-type': 'application/json',
},
});
}
export const signOutCloud = async (redirectUri?: string) => {
const traceParams = genTraceParams();
return signOut({
callbackUrl: '/',
...options,
})
return fetch(getBaseUrl() + '/api/auth/sign-out')
.then(result => {
if (result) {
if (result.ok) {
new BroadcastChannel(
CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY
).postMessage(1);
if (redirectUri && location.href !== redirectUri) {
setTimeout(() => {
location.href = redirectUri;
}, 0);
}
}
return onResolveHandleTrace(result, traceParams);
})
.catch(err => onRejectHandleTrace(err, traceParams));
};
export function buildCallbackUrl(callbackUrl: string) {
export function buildRedirectUri(callbackUrl: string) {
const params: string[][] = [];
if (environment.isDesktop && window.appInfo.schema) {
params.push(['schema', window.appInfo.schema]);