mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
refactor: remove hacky email login (#4075)
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
@@ -1,13 +0,0 @@
|
|||||||
import { isDesktop } from '@affine/env/constant';
|
|
||||||
|
|
||||||
export function buildCallbackUrl(callbackUrl: string) {
|
|
||||||
const params: string[][] = [];
|
|
||||||
if (isDesktop && window.appInfo.schema) {
|
|
||||||
params.push(['schema', window.appInfo.schema]);
|
|
||||||
}
|
|
||||||
const query =
|
|
||||||
params.length > 0
|
|
||||||
? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
|
||||||
: '';
|
|
||||||
return callbackUrl + query;
|
|
||||||
}
|
|
||||||
@@ -83,8 +83,8 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
marginTop: 30,
|
marginTop: 30,
|
||||||
}}
|
}}
|
||||||
icon={<GoogleDuotoneIcon />}
|
icon={<GoogleDuotoneIcon />}
|
||||||
onClick={useCallback(async () => {
|
onClick={useCallback(() => {
|
||||||
await signInWithGoogle();
|
signInWithGoogle();
|
||||||
}, [signInWithGoogle])}
|
}, [signInWithGoogle])}
|
||||||
>
|
>
|
||||||
{t['Continue with Google']()}
|
{t['Continue with Google']()}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||||
import type { Notification } from '@affine/component/notification-center/index.jotai';
|
import type { Notification } from '@affine/component/notification-center/index.jotai';
|
||||||
import { isDesktop } from '@affine/env/constant';
|
|
||||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||||
import { type SignInResponse } from 'next-auth/react';
|
import { type SignInResponse } from 'next-auth/react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { signInCloud } from '../../../utils/cloud-utils';
|
import { signInCloud } from '../../../utils/cloud-utils';
|
||||||
import { buildCallbackUrl } from './callback-url';
|
|
||||||
|
|
||||||
const COUNT_DOWN_TIME = 60;
|
const COUNT_DOWN_TIME = 60;
|
||||||
const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
|
const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
|
||||||
@@ -77,7 +75,7 @@ export const useAuth = ({ onNoAccess }: { onNoAccess: () => void }) => {
|
|||||||
|
|
||||||
const res = await signInCloud('email', {
|
const res = await signInCloud('email', {
|
||||||
email: email,
|
email: email,
|
||||||
callbackUrl: buildCallbackUrl('signIn'),
|
callbackUrl: '/auth/signIn',
|
||||||
redirect: false,
|
redirect: false,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|
||||||
@@ -100,7 +98,7 @@ export const useAuth = ({ onNoAccess }: { onNoAccess: () => void }) => {
|
|||||||
|
|
||||||
const res = await signInCloud('email', {
|
const res = await signInCloud('email', {
|
||||||
email: email,
|
email: email,
|
||||||
callbackUrl: buildCallbackUrl('signUp'),
|
callbackUrl: '/auth/signUp',
|
||||||
redirect: false,
|
redirect: false,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|
||||||
@@ -114,16 +112,7 @@ export const useAuth = ({ onNoAccess }: { onNoAccess: () => void }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const signInWithGoogle = useCallback(() => {
|
const signInWithGoogle = useCallback(() => {
|
||||||
if (isDesktop) {
|
signInCloud('google').catch(console.error);
|
||||||
open(
|
|
||||||
`/desktop-signin?provider=google&callback_url=${buildCallbackUrl(
|
|
||||||
'/open-app/oauth-jwt'
|
|
||||||
)}`,
|
|
||||||
'_target'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
signInCloud('google').catch(console.error);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -76,8 +76,10 @@ const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
|
|||||||
if (!urlToOpen || lastOpened === urlToOpen || !autoOpen) {
|
if (!urlToOpen || lastOpened === urlToOpen || !autoOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastOpened = urlToOpen;
|
setTimeout(() => {
|
||||||
open(urlToOpen, '_blank');
|
lastOpened = urlToOpen;
|
||||||
|
open(urlToOpen, '_blank');
|
||||||
|
}, 1000);
|
||||||
}, [urlToOpen, autoOpen]);
|
}, [urlToOpen, autoOpen]);
|
||||||
|
|
||||||
if (!urlToOpen) {
|
if (!urlToOpen) {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const DesktopLoginModal = (): ReactElement => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.events?.ui.onFinishLogin(({ success, email }) => {
|
return window.events?.ui.onFinishLogin(({ success, email }) => {
|
||||||
if (email !== signingEmail) {
|
if (email && email !== signingEmail) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSigningEmail(undefined);
|
setSigningEmail(undefined);
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
|
import { isDesktop } from '@affine/env/constant';
|
||||||
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
|
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
|
||||||
import { getCurrentStore } from '@toeverything/infra/atom';
|
import { getCurrentStore } from '@toeverything/infra/atom';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||||
import { signIn, signOut } from 'next-auth/react';
|
import { signIn, signOut } from 'next-auth/react';
|
||||||
import { startTransition } from 'react';
|
import { startTransition } from 'react';
|
||||||
|
|
||||||
export const signInCloud: typeof signIn = async (...args) => {
|
export const signInCloud: typeof signIn = async (provider, ...rest) => {
|
||||||
return signIn(...args).then(result => {
|
if (isDesktop) {
|
||||||
// do not refresh root metadata,
|
if (provider === 'google') {
|
||||||
// because the session won't change in this callback
|
open(
|
||||||
return result;
|
`/desktop-signin?provider=google&callback_url=${buildCallbackUrl(
|
||||||
});
|
'/open-app/oauth-jwt'
|
||||||
|
)}`,
|
||||||
|
'_target'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (provider === 'email') {
|
||||||
|
const [options, ...tail] = rest;
|
||||||
|
return signIn(
|
||||||
|
provider,
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
callbackUrl: buildCallbackUrl('/open-app/oauth-jwt'),
|
||||||
|
},
|
||||||
|
...tail
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('Unsupported provider');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return signIn(provider, ...rest);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signOutCloud: typeof signOut = async (...args) => {
|
export const signOutCloud: typeof signOut = async (...args) => {
|
||||||
@@ -22,3 +43,15 @@ export const signOutCloud: typeof signOut = async (...args) => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function buildCallbackUrl(callbackUrl: string) {
|
||||||
|
const params: string[][] = [];
|
||||||
|
if (isDesktop && window.appInfo.schema) {
|
||||||
|
params.push(['schema', window.appInfo.schema]);
|
||||||
|
}
|
||||||
|
const query =
|
||||||
|
params.length > 0
|
||||||
|
? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||||
|
: '';
|
||||||
|
return callbackUrl + query;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,55 +58,11 @@ async function handleAffineUrl(url: string) {
|
|||||||
logger.info('handle affine schema action', urlObj.hostname);
|
logger.info('handle affine schema action', urlObj.hostname);
|
||||||
// handle more actions here
|
// handle more actions here
|
||||||
// hostname is the action name
|
// hostname is the action name
|
||||||
if (urlObj.hostname === 'sign-in') {
|
if (urlObj.hostname === 'oauth-jwt') {
|
||||||
const urlToOpen = urlObj.search.slice(1);
|
|
||||||
if (urlToOpen) {
|
|
||||||
await handleSignIn(urlToOpen);
|
|
||||||
}
|
|
||||||
} else if (urlObj.hostname === 'oauth-jwt') {
|
|
||||||
await handleOauthJwt(url);
|
await handleOauthJwt(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: move to another place?
|
|
||||||
async function handleSignIn(url: string) {
|
|
||||||
if (url) {
|
|
||||||
try {
|
|
||||||
const mainWindow = await restoreOrCreateWindow();
|
|
||||||
mainWindow.show();
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
const email = urlObj.searchParams.get('email');
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
logger.error('no email in url', url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uiSubjects.onStartLogin.next({
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
const window = await handleOpenUrlInHiddenWindow(url);
|
|
||||||
logger.info('opened url in hidden window', window.webContents.getURL());
|
|
||||||
// check path
|
|
||||||
// - if path === /auth/{signIn,signUp}, we know sign in succeeded
|
|
||||||
// - if path === expired, we know sign in failed
|
|
||||||
const finalUrl = new URL(window.webContents.getURL());
|
|
||||||
console.log('final url', finalUrl);
|
|
||||||
// hack: wait for the hidden window to send broadcast message to the main window
|
|
||||||
// that's how next-auth works for cross-tab communication
|
|
||||||
setTimeout(() => {
|
|
||||||
window.destroy();
|
|
||||||
}, 3000);
|
|
||||||
uiSubjects.onFinishLogin.next({
|
|
||||||
success: ['/auth/signIn', '/auth/signUp'].includes(finalUrl.pathname),
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('failed to open url in popup', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOauthJwt(url: string) {
|
async function handleOauthJwt(url: string) {
|
||||||
if (url) {
|
if (url) {
|
||||||
try {
|
try {
|
||||||
@@ -114,6 +70,7 @@ async function handleOauthJwt(url: string) {
|
|||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const token = urlObj.searchParams.get('token');
|
const token = urlObj.searchParams.get('token');
|
||||||
|
const mainOrigin = new URL(mainWindow.webContents.getURL()).origin;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logger.error('no token in url', url);
|
logger.error('no token in url', url);
|
||||||
@@ -122,14 +79,22 @@ async function handleOauthJwt(url: string) {
|
|||||||
|
|
||||||
// set token to cookie
|
// set token to cookie
|
||||||
await setCookie({
|
await setCookie({
|
||||||
url: new URL(mainWindow.webContents.getURL()).origin,
|
url: mainOrigin,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
value: token,
|
value: token,
|
||||||
name: 'next-auth.session-token',
|
name: 'next-auth.session-token',
|
||||||
});
|
});
|
||||||
|
|
||||||
// force reload app
|
// hacks to refresh auth state in the main window
|
||||||
mainWindow.webContents.reload();
|
const window = await handleOpenUrlInHiddenWindow(
|
||||||
|
mainOrigin + '/auth/signIn'
|
||||||
|
);
|
||||||
|
uiSubjects.onFinishLogin.next({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
window.destroy();
|
||||||
|
}, 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('failed to open url in popup', e);
|
logger.error('failed to open url in popup', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import electronWindowState from 'electron-window-state';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { isMacOS, isWindows } from '../shared/utils';
|
import { isMacOS, isWindows } from '../shared/utils';
|
||||||
import { CLOUD_BASE_URL } from './config';
|
|
||||||
import { getExposedMeta } from './exposed';
|
import { getExposedMeta } from './exposed';
|
||||||
import { ensureHelperProcess } from './helper-process';
|
import { ensureHelperProcess } from './helper-process';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
@@ -115,7 +114,7 @@ async function createWindow() {
|
|||||||
/**
|
/**
|
||||||
* URL for main window.
|
* URL for main window.
|
||||||
*/
|
*/
|
||||||
const pageUrl = CLOUD_BASE_URL; // see protocol.ts
|
const pageUrl = process.env.DEV_SERVER_URL || 'file://.'; // see protocol.ts
|
||||||
|
|
||||||
logger.info('loading page at', pageUrl);
|
logger.info('loading page at', pageUrl);
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { net, protocol, session } from 'electron';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { CLOUD_BASE_URL } from './config';
|
import { CLOUD_BASE_URL } from './config';
|
||||||
import { setCookie } from './main-window';
|
|
||||||
import { simpleGet } from './utils';
|
|
||||||
|
|
||||||
const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql'];
|
const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql'];
|
||||||
const webStaticDir = join(__dirname, '../resources/web-static');
|
const webStaticDir = join(__dirname, '../resources/web-static');
|
||||||
@@ -16,38 +14,16 @@ async function handleHttpRequest(request: Request) {
|
|||||||
const clonedRequest = Object.assign(request.clone(), {
|
const clonedRequest = Object.assign(request.clone(), {
|
||||||
bypassCustomProtocolHandlers: true,
|
bypassCustomProtocolHandlers: true,
|
||||||
});
|
});
|
||||||
const { pathname, origin } = new URL(request.url);
|
const urlObject = new URL(request.url);
|
||||||
if (
|
if (isNetworkResource(urlObject.pathname)) {
|
||||||
!origin.startsWith(CLOUD_BASE_URL) ||
|
// just pass through (proxy)
|
||||||
isNetworkResource(pathname) ||
|
return net.fetch(CLOUD_BASE_URL + urlObject.pathname, clonedRequest);
|
||||||
process.env.DEV_SERVER_URL // when debugging locally
|
|
||||||
) {
|
|
||||||
// note: I don't find a good way to get over with 302 redirect
|
|
||||||
// by default in net.fetch, or don't know if there is a way to
|
|
||||||
// bypass http request handling to browser instead ...
|
|
||||||
if (pathname.startsWith('/api/auth/callback')) {
|
|
||||||
const originResponse = await simpleGet(request.url);
|
|
||||||
// hack: use window.webContents.session.cookies to set cookies
|
|
||||||
// since return set-cookie header in response doesn't work here
|
|
||||||
for (const [, cookie] of originResponse.headers.filter(
|
|
||||||
p => p[0] === 'set-cookie'
|
|
||||||
)) {
|
|
||||||
await setCookie(origin, cookie);
|
|
||||||
}
|
|
||||||
return new Response(originResponse.body, {
|
|
||||||
headers: originResponse.headers,
|
|
||||||
status: originResponse.statusCode,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// just pass through (proxy)
|
|
||||||
return net.fetch(request.url, clonedRequest);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// this will be file types (in the web-static folder)
|
// this will be file types (in the web-static folder)
|
||||||
let filepath = '';
|
let filepath = '';
|
||||||
// if is a file type, load the file in resources
|
// if is a file type, load the file in resources
|
||||||
if (pathname.split('/').at(-1)?.includes('.')) {
|
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
|
||||||
filepath = join(webStaticDir, decodeURIComponent(pathname));
|
filepath = join(webStaticDir, decodeURIComponent(urlObject.pathname));
|
||||||
} else {
|
} else {
|
||||||
// else, fallback to load the index.html instead
|
// else, fallback to load the index.html instead
|
||||||
filepath = join(webStaticDir, 'index.html');
|
filepath = join(webStaticDir, 'index.html');
|
||||||
@@ -57,11 +33,7 @@ async function handleHttpRequest(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerProtocol() {
|
export function registerProtocol() {
|
||||||
protocol.handle('http', request => {
|
protocol.handle('file', request => {
|
||||||
return handleHttpRequest(request);
|
|
||||||
});
|
|
||||||
|
|
||||||
protocol.handle('https', request => {
|
|
||||||
return handleHttpRequest(request);
|
return handleHttpRequest(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { uiSubjects } from './subject';
|
|||||||
*/
|
*/
|
||||||
export const uiEvents = {
|
export const uiEvents = {
|
||||||
onFinishLogin: (
|
onFinishLogin: (
|
||||||
fn: (result: { success: boolean; email: string }) => void
|
fn: (result: { success: boolean; email?: string }) => void
|
||||||
) => {
|
) => {
|
||||||
const sub = uiSubjects.onFinishLogin.subscribe(fn);
|
const sub = uiSubjects.onFinishLogin.subscribe(fn);
|
||||||
return () => {
|
return () => {
|
||||||
sub.unsubscribe();
|
sub.unsubscribe();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onStartLogin: (fn: (opts: { email: string }) => void) => {
|
onStartLogin: (fn: (opts: { email?: string }) => void) => {
|
||||||
const sub = uiSubjects.onStartLogin.subscribe(fn);
|
const sub = uiSubjects.onStartLogin.subscribe(fn);
|
||||||
return () => {
|
return () => {
|
||||||
sub.unsubscribe();
|
sub.unsubscribe();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
export const uiSubjects = {
|
export const uiSubjects = {
|
||||||
onStartLogin: new Subject<{ email: string }>(),
|
onStartLogin: new Subject<{ email?: string }>(),
|
||||||
onFinishLogin: new Subject<{ success: boolean; email: string }>(),
|
onFinishLogin: new Subject<{ success: boolean; email?: string }>(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,23 +20,6 @@ import { getUtcTimestamp, UserClaim } from './service';
|
|||||||
|
|
||||||
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
|
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
|
||||||
|
|
||||||
function getSchemaFromCallbackUrl(origin: string, callbackUrl: string) {
|
|
||||||
const { searchParams } = new URL(callbackUrl, origin);
|
|
||||||
return searchParams.has('schema') ? searchParams.get('schema') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapUrlWithOpenApp(
|
|
||||||
origin: string,
|
|
||||||
url: string,
|
|
||||||
schema: string | null
|
|
||||||
) {
|
|
||||||
if (schema) {
|
|
||||||
const urlWithSchema = `${schema}://sign-in?${url}`;
|
|
||||||
return `${origin}/open-app?url=${encodeURIComponent(urlWithSchema)}`;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||||
provide: NextAuthOptionsProvide,
|
provide: NextAuthOptionsProvide,
|
||||||
useFactory(config: Config, prisma: PrismaService, mailer: MailService) {
|
useFactory(config: Config, prisma: PrismaService, mailer: MailService) {
|
||||||
@@ -88,17 +71,12 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
|||||||
from: config.auth.email.sender,
|
from: config.auth.email.sender,
|
||||||
async sendVerificationRequest(params: SendVerificationRequestParams) {
|
async sendVerificationRequest(params: SendVerificationRequestParams) {
|
||||||
const { identifier, url, provider } = params;
|
const { identifier, url, provider } = params;
|
||||||
const { searchParams, origin } = new URL(url);
|
const { searchParams } = new URL(url);
|
||||||
const callbackUrl = searchParams.get('callbackUrl') || '';
|
const callbackUrl = searchParams.get('callbackUrl') || '';
|
||||||
if (!callbackUrl) {
|
if (!callbackUrl) {
|
||||||
throw new Error('callbackUrl is not set');
|
throw new Error('callbackUrl is not set');
|
||||||
}
|
}
|
||||||
|
const result = await mailer.sendSignInEmail(url, {
|
||||||
const schema = getSchemaFromCallbackUrl(origin, callbackUrl);
|
|
||||||
const wrappedUrl = wrapUrlWithOpenApp(origin, url, schema);
|
|
||||||
|
|
||||||
// hack: check if link is opened via desktop
|
|
||||||
const result = await mailer.sendSignInEmail(wrappedUrl, {
|
|
||||||
to: identifier,
|
to: identifier,
|
||||||
from: provider.from,
|
from: provider.from,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,9 +266,9 @@ export interface WorkspaceEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UIEvents {
|
export interface UIEvents {
|
||||||
onStartLogin: (fn: (options: { email: string }) => void) => () => void;
|
onStartLogin: (fn: (options: { email?: string }) => void) => () => void;
|
||||||
onFinishLogin: (
|
onFinishLogin: (
|
||||||
fn: (result: { success: boolean; email: string }) => void
|
fn: (result: { success: boolean; email?: string }) => void
|
||||||
) => () => void;
|
) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user