fix(core): redirect to old page after login via 404 page (#8588)

fix AF-1487

When visit a cloud worskapce page without login, we should allow the user to redirect back to that page after login.

The logic is only added for this case, including login with email magin link + google login. Login with password is already working without changes.
This commit is contained in:
pengx17
2024-10-25 03:43:00 +00:00
parent 08319bc560
commit 8f694aceb7
13 changed files with 135 additions and 35 deletions
@@ -111,7 +111,6 @@ export class OAuthController {
await this.auth.setCookies(req, res, user.id); await this.auth.setCookies(req, res, user.id);
res.send({ res.send({
id: user.id, id: user.id,
/* @deprecated */
redirectUri: state.redirectUri, redirectUri: state.redirectUri,
}); });
} }
@@ -19,6 +19,7 @@ import { Captcha, useCaptcha } from './use-captcha';
export const AfterSignInSendEmail = ({ export const AfterSignInSendEmail = ({
setAuthData: setAuth, setAuthData: setAuth,
email, email,
redirectUrl,
}: AuthPanelProps<'afterSignInSendEmail'>) => { }: AuthPanelProps<'afterSignInSendEmail'>) => {
const [resendCountDown, setResendCountDown] = useState(60); const [resendCountDown, setResendCountDown] = useState(60);
@@ -44,7 +45,12 @@ export const AfterSignInSendEmail = ({
try { try {
if (verifyToken) { if (verifyToken) {
setResendCountDown(60); setResendCountDown(60);
await authService.sendEmailMagicLink(email, verifyToken, challenge); await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -53,7 +59,7 @@ export const AfterSignInSendEmail = ({
}); });
} }
setIsSending(false); setIsSending(false);
}, [authService, challenge, email, verifyToken]); }, [authService, challenge, email, redirectUrl, verifyToken]);
const onSignInWithPasswordClick = useCallback(() => { const onSignInWithPasswordClick = useCallback(() => {
setAuth({ state: 'signInWithPassword' }); setAuth({ state: 'signInWithPassword' });
@@ -19,7 +19,7 @@ import { Captcha, useCaptcha } from './use-captcha';
export const AfterSignUpSendEmail: FC< export const AfterSignUpSendEmail: FC<
AuthPanelProps<'afterSignUpSendEmail'> AuthPanelProps<'afterSignUpSendEmail'>
> = ({ setAuthData, email }) => { > = ({ setAuthData, email, redirectUrl }) => {
const [resendCountDown, setResendCountDown] = useState(60); const [resendCountDown, setResendCountDown] = useState(60);
useEffect(() => { useEffect(() => {
@@ -42,7 +42,12 @@ export const AfterSignUpSendEmail: FC<
setIsSending(true); setIsSending(true);
try { try {
if (verifyToken) { if (verifyToken) {
await authService.sendEmailMagicLink(email, verifyToken, challenge); await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
} }
setResendCountDown(60); setResendCountDown(60);
} catch (err) { } catch (err) {
@@ -52,7 +57,7 @@ export const AfterSignUpSendEmail: FC<
}); });
} }
setIsSending(false); setIsSending(false);
}, [authService, challenge, email, verifyToken]); }, [authService, challenge, email, redirectUrl, verifyToken]);
return ( return (
<> <>
@@ -30,6 +30,7 @@ export type AuthPanelProps<State extends AuthAtomData['state']> = {
updates: { state: T } & Difference<AuthAtomType<State>, AuthAtomType<T>> updates: { state: T } & Difference<AuthAtomType<State>, AuthAtomType<T>>
) => void; ) => void;
onSkip?: () => void; onSkip?: () => void;
redirectUrl?: string;
} & Extract<AuthAtomData, { state: State }>; } & Extract<AuthAtomData, { state: State }>;
const config: { const config: {
@@ -58,7 +59,13 @@ export function AuthModal() {
); );
} }
export function AuthPanel({ onSkip }: { onSkip?: () => void }) { export function AuthPanel({
onSkip,
redirectUrl,
}: {
onSkip?: () => void;
redirectUrl?: string | null;
}) {
const t = useI18n(); const t = useI18n();
const [authAtomValue, setAuthAtom] = useAtom(authAtom); const [authAtomValue, setAuthAtom] = useAtom(authAtom);
const authService = useService(AuthService); const authService = useService(AuthService);
@@ -98,6 +105,7 @@ export function AuthPanel({ onSkip }: { onSkip?: () => void }) {
const props = { const props = {
...authAtomValue, ...authAtomValue,
onSkip, onSkip,
redirectUrl,
setAuthData, setAuthData,
}; };
@@ -29,7 +29,7 @@ const OAuthProviderMap: Record<
}, },
}; };
export function OAuth() { export function OAuth({ redirectUrl }: { redirectUrl?: string }) {
const serverConfig = useService(ServerConfigService).serverConfig; const serverConfig = useService(ServerConfigService).serverConfig;
const oauth = useLiveData(serverConfig.features$.map(r => r?.oauth)); const oauth = useLiveData(serverConfig.features$.map(r => r?.oauth));
const oauthProviders = useLiveData( const oauthProviders = useLiveData(
@@ -41,25 +41,43 @@ export function OAuth() {
} }
return oauthProviders?.map(provider => ( return oauthProviders?.map(provider => (
<OAuthProvider key={provider} provider={provider} /> <OAuthProvider
key={provider}
provider={provider}
redirectUrl={redirectUrl}
/>
)); ));
} }
function OAuthProvider({ provider }: { provider: OAuthProviderType }) { function OAuthProvider({
provider,
redirectUrl,
}: {
provider: OAuthProviderType;
redirectUrl?: string;
}) {
const { icon } = OAuthProviderMap[provider]; const { icon } = OAuthProviderMap[provider];
const onClick = useCallback(() => { const onClick = useCallback(() => {
let oauthUrl = const params = new URLSearchParams();
(BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
? BUILD_CONFIG.serverUrlPrefix
: '') + `/oauth/login?provider=${provider}`;
if (BUILD_CONFIG.isElectron) { params.set('provider', provider);
oauthUrl += `&client=${appInfo?.schema}`;
if (redirectUrl) {
params.set('redirect_uri', redirectUrl);
} }
if (BUILD_CONFIG.isElectron && appInfo) {
params.set('client', appInfo.schema);
}
const oauthUrl =
(BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
? BUILD_CONFIG.serverUrlPrefix
: '') + `/oauth/login?${params.toString()}`;
popupWindow(oauthUrl); popupWindow(oauthUrl);
}, [provider]); }, [provider, redirectUrl]);
return ( return (
<Button <Button
@@ -141,6 +141,7 @@ export const SendEmail = ({
setAuthData, setAuthData,
email, email,
emailType, emailType,
// todo(@pengx17): impl redirectUrl for sendEmail?
}: AuthPanelProps<'sendEmail'>) => { }: AuthPanelProps<'sendEmail'>) => {
const t = useI18n(); const t = useI18n();
const serverConfig = useService(ServerConfigService).serverConfig; const serverConfig = useService(ServerConfigService).serverConfig;
@@ -19,6 +19,7 @@ import { useCaptcha } from './use-captcha';
export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
setAuthData, setAuthData,
email, email,
redirectUrl,
}) => { }) => {
const t = useI18n(); const t = useI18n();
const authService = useService(AuthService); const authService = useService(AuthService);
@@ -62,7 +63,12 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
setSendingEmail(true); setSendingEmail(true);
try { try {
if (verifyToken) { if (verifyToken) {
await authService.sendEmailMagicLink(email, verifyToken, challenge); await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setAuthData({ state: 'afterSignInSendEmail' }); setAuthData({ state: 'afterSignInSendEmail' });
} }
} catch (err) { } catch (err) {
@@ -73,7 +79,15 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
// TODO(@eyhn): handle error better // TODO(@eyhn): handle error better
} }
setSendingEmail(false); setSendingEmail(false);
}, [sendingEmail, verifyToken, authService, email, challenge, setAuthData]); }, [
sendingEmail,
verifyToken,
authService,
email,
challenge,
redirectUrl,
setAuthData,
]);
const sendChangePasswordEmail = useCallback(() => { const sendChangePasswordEmail = useCallback(() => {
setAuthData({ state: 'sendEmail', emailType: 'changePassword' }); setAuthData({ state: 'sendEmail', emailType: 'changePassword' });
@@ -24,6 +24,7 @@ function validateEmail(email: string) {
export const SignIn: FC<AuthPanelProps<'signIn'>> = ({ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
setAuthData: setAuthState, setAuthData: setAuthState,
onSkip, onSkip,
redirectUrl,
}) => { }) => {
const t = useI18n(); const t = useI18n();
const authService = useService(AuthService); const authService = useService(AuthService);
@@ -59,14 +60,24 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
email, email,
}); });
} else { } else {
await authService.sendEmailMagicLink(email, verifyToken, challenge); await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setAuthState({ setAuthState({
state: 'afterSignInSendEmail', state: 'afterSignInSendEmail',
email, email,
}); });
} }
} else { } else {
await authService.sendEmailMagicLink(email, verifyToken, challenge); await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setAuthState({ setAuthState({
state: 'afterSignUpSendEmail', state: 'afterSignUpSendEmail',
email, email,
@@ -87,6 +98,7 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
authService, authService,
challenge, challenge,
email, email,
redirectUrl,
refreshChallenge, refreshChallenge,
setAuthState, setAuthState,
verifyToken, verifyToken,
@@ -99,7 +111,7 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
subTitle={t['com.affine.brand.affineCloud']()} subTitle={t['com.affine.brand.affineCloud']()}
/> />
<OAuth /> <OAuth redirectUrl={redirectUrl} />
<div className={style.authModalContent}> <div className={style.authModalContent}>
<AuthInput <AuthInput
@@ -44,6 +44,10 @@ export const PageNotFound = ({
apis?.ui.pingAppLayoutReady().catch(console.error); apis?.ui.pingAppLayoutReady().catch(console.error);
}, []); }, []);
// not using workbench location or router location deliberately
// strip the origin
const currentUrl = window.location.href.replace(window.location.origin, '');
return ( return (
<> <>
{noPermission ? ( {noPermission ? (
@@ -51,7 +55,7 @@ export const PageNotFound = ({
user={account} user={account}
onBack={handleBackButtonClick} onBack={handleBackButtonClick}
onSignOut={handleOpenSignOutModal} onSignOut={handleOpenSignOutModal}
signInComponent={<SignIn />} signInComponent={<SignIn redirectUrl={currentUrl} />}
/> />
) : ( ) : (
<NotFoundPage <NotFoundPage
@@ -1,5 +1,5 @@
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { import {
type LoaderFunction, type LoaderFunction,
redirect, redirect,
@@ -59,17 +59,29 @@ export const Component = () => {
const data = useLoaderData() as LoaderData; const data = useLoaderData() as LoaderData;
const nav = useNavigate(); const nav = useNavigate();
// loader data from useLoaderData is not reactive, so that we can safely
// assume the effect below is only triggered once
const triggeredRef = useRef(false);
useEffect(() => { useEffect(() => {
if (triggeredRef.current) {
return;
}
triggeredRef.current = true;
auth auth
.signInMagicLink(data.email, data.token) .signInMagicLink(data.email, data.token)
.then(() => { .then(() => {
nav(data.redirectUri ?? '/'); const subscription = auth.session.status$.subscribe(status => {
if (status === 'authenticated') {
nav(data.redirectUri ?? '/');
subscription?.unsubscribe();
}
});
}) })
.catch(e => { .catch(e => {
nav(`/sign-in?error=${encodeURIComponent(e.message)}`); nav(`/sign-in?error=${encodeURIComponent(e.message)}`);
}); });
}, [data, auth, nav]); }, [auth, data, data.email, data.redirectUri, data.token, nav]);
return null; return null;
}; };
@@ -1,5 +1,5 @@
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { import {
type LoaderFunction, type LoaderFunction,
redirect, redirect,
@@ -62,9 +62,17 @@ export const Component = () => {
const auth = useService(AuthService); const auth = useService(AuthService);
const data = useLoaderData() as LoaderData; const data = useLoaderData() as LoaderData;
// loader data from useLoaderData is not reactive, so that we can safely
// assume the effect below is only triggered once
const triggeredRef = useRef(false);
const nav = useNavigate(); const nav = useNavigate();
useEffect(() => { useEffect(() => {
if (triggeredRef.current) {
return;
}
triggeredRef.current = true;
auth auth
.signInOauth(data.code, data.state, data.provider) .signInOauth(data.code, data.state, data.provider)
.then(({ redirectUri }) => { .then(({ redirectUri }) => {
@@ -12,7 +12,11 @@ import {
useNavigateHelper, useNavigateHelper,
} from '../../../components/hooks/use-navigate-helper'; } from '../../../components/hooks/use-navigate-helper';
export const SignIn = () => { export const SignIn = ({
redirectUrl: redirectUrlFromProps,
}: {
redirectUrl?: string;
}) => {
const session = useService(AuthService).session; const session = useService(AuthService).session;
const status = useLiveData(session.status$); const status = useLiveData(session.status$);
const isRevalidating = useLiveData(session.isRevalidating$); const isRevalidating = useLiveData(session.isRevalidating$);
@@ -20,12 +24,12 @@ export const SignIn = () => {
const { jumpToIndex } = useNavigateHelper(); const { jumpToIndex } = useNavigateHelper();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const isLoggedIn = status === 'authenticated' && !isRevalidating; const isLoggedIn = status === 'authenticated' && !isRevalidating;
const redirectUrl = redirectUrlFromProps ?? searchParams.get('redirect_uri');
useEffect(() => { useEffect(() => {
if (isLoggedIn) { if (isLoggedIn) {
const redirectUri = searchParams.get('redirect_uri'); if (redirectUrl) {
if (redirectUri) { navigate(redirectUrl, {
navigate(redirectUri, {
replace: true, replace: true,
}); });
} else { } else {
@@ -34,12 +38,12 @@ export const SignIn = () => {
}); });
} }
} }
}, [jumpToIndex, navigate, isLoggedIn, searchParams]); }, [jumpToIndex, navigate, isLoggedIn, redirectUrl, searchParams]);
return ( return (
<SignInPageContainer> <SignInPageContainer>
<div style={{ maxWidth: '400px', width: '100%' }}> <div style={{ maxWidth: '400px', width: '100%' }}>
<AuthPanel onSkip={jumpToIndex} /> <AuthPanel onSkip={jumpToIndex} redirectUrl={redirectUrl} />
</div> </div>
</SignInPageContainer> </SignInPageContainer>
); );
@@ -112,17 +112,26 @@ export class AuthService extends Service {
async sendEmailMagicLink( async sendEmailMagicLink(
email: string, email: string,
verifyToken: string, verifyToken: string,
challenge?: string challenge?: string,
redirectUrl?: string // url to redirect to after signed-in
) { ) {
track.$.$.auth.signIn({ method: 'magic-link' }); track.$.$.auth.signIn({ method: 'magic-link' });
try { try {
const magicLinkUrlParams = new URLSearchParams();
if (redirectUrl) {
magicLinkUrlParams.set('redirect_uri', redirectUrl);
}
magicLinkUrlParams.set(
'client',
BUILD_CONFIG.isElectron && appInfo ? appInfo.schema : 'web'
);
await this.fetchService.fetch('/api/auth/sign-in', { await this.fetchService.fetch('/api/auth/sign-in', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
email, email,
// we call it [callbackUrl] instead of [redirect_uri] // we call it [callbackUrl] instead of [redirect_uri]
// to make it clear the url is used to finish the sign-in process instead of redirect after signed-in // to make it clear the url is used to finish the sign-in process instead of redirect after signed-in
callbackUrl: `/magic-link?client=${BUILD_CONFIG.isElectron ? appInfo?.schema : 'web'}`, callbackUrl: `/magic-link?${magicLinkUrlParams.toString()}`,
}), }),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',