mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-10 22:56:24 +08:00
fix(editor): import & save logic (#15098)
fix #15080 fix #15085 fix #15031 fix #15094 #### PR Dependency Tree * **PR #15098** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved code-block paste behavior for plain-text insertion * Fixed block selection ordering to reflect document model * Made table cell formatting resilient to conversion errors * Ensured user feature list is consistently returned as an array * **Refactor** * Streamlined authentication session fetch and profile enrichment flow * **Tests** * Added tests for markdown blockquote list preservation * Added authentication session validation tests <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -270,6 +270,54 @@ Hello world
|
||||
expect(meta?.tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('preserves list text inside blockquotes without list blocks', async () => {
|
||||
const markdown = `> **Shopping List:**
|
||||
> - Apples
|
||||
> - Bananas
|
||||
> - Oranges
|
||||
`;
|
||||
const mdAdapter = new MarkdownAdapter(createJob(), provider);
|
||||
const snapshot = await mdAdapter.toDocSnapshot({
|
||||
file: markdown,
|
||||
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
|
||||
});
|
||||
|
||||
expect(simplifyBlockForSnapshot(snapshot.blocks, new Map())).toMatchObject({
|
||||
children: [
|
||||
{
|
||||
flavour: 'affine:note',
|
||||
children: [
|
||||
{
|
||||
flavour: 'affine:paragraph',
|
||||
type: 'quote',
|
||||
delta: [
|
||||
{ insert: 'Shopping List:' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Apples' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Bananas' },
|
||||
{ insert: '\n' },
|
||||
{ insert: '- ' },
|
||||
{ insert: 'Oranges' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const exported = await mdAdapter.fromDocSnapshot({
|
||||
snapshot,
|
||||
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
|
||||
});
|
||||
expect(exported.file).toContain('> **Shopping List:**');
|
||||
expect(exported.file).toContain('> \\- Apples');
|
||||
expect(exported.file).toContain('> \\- Bananas');
|
||||
expect(exported.file).toContain('> \\- Oranges');
|
||||
});
|
||||
|
||||
test('imports obsidian vault fixtures', async () => {
|
||||
const schema = new Schema().register(AffineSchemas);
|
||||
const collection = new TestWorkspace();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
|
||||
import type { RichText } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
HtmlAdapter,
|
||||
pasteMiddleware,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
LifeCycleWatcher,
|
||||
LifeCycleWatcherIdentifier,
|
||||
StdIdentifier,
|
||||
TextSelection,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
@@ -103,6 +105,30 @@ export class CodeBlockClipboardController extends LifeCycleWatcher {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
const textSelection = this.std.selection.find(TextSelection);
|
||||
const plainText = e.clipboardData
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
const selectedBlockId = textSelection?.from.blockId;
|
||||
const codeBlock = selectedBlockId
|
||||
? this.std.store.getBlock(selectedBlockId)?.model
|
||||
: null;
|
||||
if (plainText && codeBlock?.flavour === 'affine:code' && selectedBlockId) {
|
||||
const richText = this.std.view
|
||||
.getBlock(selectedBlockId)
|
||||
?.querySelector<RichText>('rich-text');
|
||||
const inlineEditor = richText?.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (inlineEditor && inlineRange) {
|
||||
inlineEditor.insertText(inlineRange, plainText);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + plainText.length,
|
||||
length: 0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this.std.store.captureSync();
|
||||
this.std.command
|
||||
.chain()
|
||||
|
||||
@@ -54,9 +54,9 @@ type Cell = {
|
||||
value: string | { delta: DeltaInsert[] };
|
||||
};
|
||||
export const processTable = (
|
||||
columns: ColumnDataType[],
|
||||
children: BlockSnapshot[],
|
||||
cells: SerializedCells
|
||||
columns: ColumnDataType[] = [],
|
||||
children: BlockSnapshot[] = [],
|
||||
cells: SerializedCells = {}
|
||||
): Table => {
|
||||
const table: Table = {
|
||||
headers: columns,
|
||||
@@ -90,13 +90,17 @@ export const processTable = (
|
||||
return;
|
||||
}
|
||||
let value: string | { delta: DeltaInsert[] };
|
||||
if (isDelta(cell.value)) {
|
||||
value = cell.value;
|
||||
} else {
|
||||
value = property.config.rawValue.toString({
|
||||
value: cell.value,
|
||||
data: col.data,
|
||||
});
|
||||
try {
|
||||
if (isDelta(cell.value)) {
|
||||
value = cell.value;
|
||||
} else {
|
||||
value = property.config.rawValue.toString({
|
||||
value: cell.value,
|
||||
data: col.data,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
value = '';
|
||||
}
|
||||
row.cells.push({
|
||||
value,
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
IN_PARAGRAPH_NODE_CONTEXT_KEY,
|
||||
isCalloutNode,
|
||||
type MarkdownAST,
|
||||
type MarkdownDeltaConverter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Heading } from 'mdast';
|
||||
import type { Blockquote, Heading, List, ListItem } from 'mdast';
|
||||
|
||||
/**
|
||||
* Extend the HeadingData type to include the collapsed property
|
||||
@@ -24,6 +25,131 @@ const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']);
|
||||
const isParagraphMDASTType = (node: MarkdownAST) =>
|
||||
PARAGRAPH_MDAST_TYPE.has(node.type);
|
||||
|
||||
const joinDeltaLines = (
|
||||
lines: DeltaInsert[][],
|
||||
prefix?: string
|
||||
): DeltaInsert[] => {
|
||||
const deltas: DeltaInsert[] = [];
|
||||
lines.forEach(line => {
|
||||
if (deltas.length) deltas.push({ insert: '\n' });
|
||||
if (prefix) deltas.push({ insert: prefix });
|
||||
deltas.push(...line);
|
||||
});
|
||||
return deltas;
|
||||
};
|
||||
|
||||
const flattenListItemToDelta = (
|
||||
node: ListItem,
|
||||
deltaConverter: MarkdownDeltaConverter,
|
||||
prefix: string,
|
||||
depth: number
|
||||
): DeltaInsert[] => {
|
||||
const firstParagraph = node.children[0];
|
||||
const lines: DeltaInsert[][] = [];
|
||||
if (firstParagraph?.type === 'paragraph') {
|
||||
lines.push([
|
||||
{ insert: prefix },
|
||||
...deltaConverter.astToDelta(firstParagraph),
|
||||
]);
|
||||
} else {
|
||||
lines.push([{ insert: prefix.trimEnd() }]);
|
||||
}
|
||||
node.children
|
||||
.slice(firstParagraph?.type === 'paragraph' ? 1 : 0)
|
||||
.forEach(child => {
|
||||
const delta = flattenMarkdownBlockToDelta(
|
||||
child as MarkdownAST,
|
||||
deltaConverter,
|
||||
depth + 1
|
||||
);
|
||||
if (delta.length) {
|
||||
lines.push(delta);
|
||||
}
|
||||
});
|
||||
return joinDeltaLines(lines);
|
||||
};
|
||||
|
||||
const flattenMarkdownBlockToDelta = (
|
||||
node: MarkdownAST,
|
||||
deltaConverter: MarkdownDeltaConverter,
|
||||
depth = 0
|
||||
): DeltaInsert[] => {
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
return deltaConverter.astToDelta(node);
|
||||
case 'list': {
|
||||
const list = node as List;
|
||||
return joinDeltaLines(
|
||||
list.children.map((item, index) => {
|
||||
const order = (list.start ?? 1) + index;
|
||||
const prefix =
|
||||
' '.repeat(depth) + (list.ordered ? `${order}. ` : '- ');
|
||||
return flattenListItemToDelta(item, deltaConverter, prefix, depth);
|
||||
})
|
||||
);
|
||||
}
|
||||
case 'blockquote':
|
||||
return flattenBlockquoteToDelta(node as Blockquote, deltaConverter);
|
||||
default:
|
||||
return 'children' in node
|
||||
? joinDeltaLines(
|
||||
(node.children as MarkdownAST[]).map(child =>
|
||||
flattenMarkdownBlockToDelta(child, deltaConverter, depth)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
};
|
||||
|
||||
const flattenBlockquoteToDelta = (
|
||||
node: Blockquote,
|
||||
deltaConverter: MarkdownDeltaConverter
|
||||
) =>
|
||||
joinDeltaLines(
|
||||
node.children.map(child =>
|
||||
flattenMarkdownBlockToDelta(child as MarkdownAST, deltaConverter)
|
||||
)
|
||||
);
|
||||
|
||||
const getSnapshotTextDelta = (node: BlockSnapshot): DeltaInsert[] => {
|
||||
const text = (node.props.text ?? { delta: [] }) as {
|
||||
delta: DeltaInsert[];
|
||||
};
|
||||
return text.delta;
|
||||
};
|
||||
|
||||
const flattenSnapshotBlockToDelta = (
|
||||
node: BlockSnapshot,
|
||||
depth = 0
|
||||
): DeltaInsert[] => {
|
||||
if (node.flavour === 'affine:list') {
|
||||
const type = node.props.type;
|
||||
const order = (node.props.order as number | undefined) ?? 1;
|
||||
const prefix =
|
||||
' '.repeat(depth) + (type === 'numbered' ? `${order}. ` : '- ');
|
||||
return joinDeltaLines([
|
||||
[{ insert: prefix }, ...getSnapshotTextDelta(node)],
|
||||
...node.children.map(child =>
|
||||
flattenSnapshotBlockToDelta(child, depth + 1)
|
||||
),
|
||||
]);
|
||||
}
|
||||
return joinDeltaLines([
|
||||
getSnapshotTextDelta(node),
|
||||
...node.children.map(child => flattenSnapshotBlockToDelta(child, depth)),
|
||||
]);
|
||||
};
|
||||
|
||||
const flattenQuoteSnapshotToDelta = (
|
||||
text: DeltaInsert[],
|
||||
children: BlockSnapshot[]
|
||||
) =>
|
||||
joinDeltaLines([
|
||||
text,
|
||||
...children.map(child => flattenSnapshotBlockToDelta(child)),
|
||||
]);
|
||||
|
||||
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: ParagraphBlockSchema.model.flavour,
|
||||
@@ -93,7 +219,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
type: 'quote',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node),
|
||||
delta: flattenBlockquoteToDelta(
|
||||
o.node as Blockquote,
|
||||
deltaConverter
|
||||
),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
@@ -160,6 +289,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
break;
|
||||
}
|
||||
case 'quote': {
|
||||
const quoteDelta = flattenQuoteSnapshotToDelta(
|
||||
text.delta,
|
||||
o.node.children
|
||||
);
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
@@ -171,12 +304,13 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: deltaConverter.deltaToAST(text.delta),
|
||||
children: deltaConverter.deltaToAST(quoteDelta),
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,32 +129,35 @@ export const getSelectedBlocksCommand: Command<
|
||||
dirtyResult = dirtyResult.filter(ctx.filter);
|
||||
}
|
||||
|
||||
const getModelPath = (el: BlockComponent) => {
|
||||
const path: number[] = [];
|
||||
let model = el.model;
|
||||
while (model) {
|
||||
const parent = ctx.std.store.getParent(model.id);
|
||||
if (!parent) break;
|
||||
path.unshift(parent.children.findIndex(child => child.id === model.id));
|
||||
model = parent;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const compareByModelPath = (a: BlockComponent, b: BlockComponent) => {
|
||||
if (a === b) return 0;
|
||||
const aPath = getModelPath(a);
|
||||
const bPath = getModelPath(b);
|
||||
const length = Math.min(aPath.length, bPath.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = aPath[i] - bPath[i];
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return aPath.length - bPath.length;
|
||||
};
|
||||
|
||||
// remove duplicate elements
|
||||
const result: BlockComponent[] = dirtyResult
|
||||
.filter((el, index) => dirtyResult.indexOf(el) === index)
|
||||
// sort by document position
|
||||
.sort((a, b) => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const position = a.compareDocumentPosition(b);
|
||||
if (
|
||||
position & Node.DOCUMENT_POSITION_FOLLOWING ||
|
||||
position & Node.DOCUMENT_POSITION_CONTAINED_BY
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
position & Node.DOCUMENT_POSITION_PRECEDING ||
|
||||
position & Node.DOCUMENT_POSITION_CONTAINS
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
// sort by model tree position, which is the order used for paste/export
|
||||
.sort(compareByModelPath);
|
||||
|
||||
if (result.length === 0) return;
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ export interface CurrentUserProfileSnapshot {
|
||||
emailVerified: boolean;
|
||||
hasPassword: boolean | null;
|
||||
avatarUrl: string | null;
|
||||
features?: string[];
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface UserSettingsSnapshot {
|
||||
|
||||
@@ -46,7 +46,7 @@ export class UserFeature extends Entity {
|
||||
if (account?.id !== accountId) return;
|
||||
return {
|
||||
userId: account.id,
|
||||
features: account.info?.features?.map(feature =>
|
||||
features: (account.info?.features ?? []).map(feature =>
|
||||
mapRealtimeEnum(FeatureType, feature, 'user feature')
|
||||
),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { AuthProvider } from '@affine/core/modules/cloud/provider/auth';
|
||||
import { FetchService } from '@affine/core/modules/cloud/services/fetch';
|
||||
import { GraphQLService } from '@affine/core/modules/cloud/services/graphql';
|
||||
import { ServerService } from '@affine/core/modules/cloud/services/server';
|
||||
import { AuthStore } from '@affine/core/modules/cloud/stores/auth';
|
||||
import { GlobalState, NbstoreService } from '@affine/core/modules/storage';
|
||||
import { Framework } from '@toeverything/infra';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
function createStore({
|
||||
fetch,
|
||||
request,
|
||||
}: {
|
||||
fetch: (input: string, init?: RequestInit) => Promise<Response>;
|
||||
request: (op: string, input: object) => Promise<unknown>;
|
||||
}) {
|
||||
const framework = new Framework();
|
||||
framework.service(FetchService, { fetch } as any);
|
||||
framework.service(GraphQLService, {} as any);
|
||||
framework.impl(GlobalState, {} as any);
|
||||
framework.service(ServerService, {
|
||||
server: { id: 'test-server' },
|
||||
} as any);
|
||||
framework.impl(AuthProvider, {} as any);
|
||||
framework.service(NbstoreService, {
|
||||
realtime: { request },
|
||||
} as any);
|
||||
framework.store(AuthStore, [
|
||||
FetchService,
|
||||
GraphQLService,
|
||||
GlobalState,
|
||||
ServerService,
|
||||
AuthProvider,
|
||||
NbstoreService,
|
||||
]);
|
||||
return framework.provider().get(AuthStore);
|
||||
}
|
||||
|
||||
describe('AuthStore', () => {
|
||||
test('loads account profile from realtime after auth session bootstrap', async () => {
|
||||
const authMethods = {
|
||||
password: { bound: true },
|
||||
oauth: { bound: false, providers: [] },
|
||||
passkey: { bound: false, count: 0 },
|
||||
};
|
||||
const fetch = vi.fn(async (input: string) => {
|
||||
if (input === '/api/auth/session') {
|
||||
return {
|
||||
json: async () => ({ user: { id: 'u1' } }),
|
||||
} as Response;
|
||||
}
|
||||
if (input === '/api/auth/methods') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => authMethods,
|
||||
} as Response;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${input}`);
|
||||
});
|
||||
const request = vi.fn(async () => ({
|
||||
user: {
|
||||
id: 'u1',
|
||||
email: 'u1@affine.pro',
|
||||
name: 'User',
|
||||
emailVerified: true,
|
||||
hasPassword: true,
|
||||
avatarUrl: null,
|
||||
features: ['Admin'],
|
||||
},
|
||||
}));
|
||||
const store = createStore({ fetch, request });
|
||||
|
||||
await expect(store.fetchSession()).resolves.toEqual({
|
||||
user: {
|
||||
id: 'u1',
|
||||
email: 'u1@affine.pro',
|
||||
name: 'User',
|
||||
emailVerified: true,
|
||||
hasPassword: true,
|
||||
avatarUrl: null,
|
||||
features: ['Admin'],
|
||||
authMethods,
|
||||
},
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith('user.profile.get', {});
|
||||
});
|
||||
|
||||
test('rejects mismatched realtime profile and auth session', async () => {
|
||||
const fetch = vi.fn(async () => {
|
||||
return {
|
||||
json: async () => ({ user: { id: 'u1' } }),
|
||||
} as Response;
|
||||
});
|
||||
const request = vi.fn(async () => ({
|
||||
user: {
|
||||
id: 'u2',
|
||||
email: 'u2@affine.pro',
|
||||
name: 'User',
|
||||
emailVerified: true,
|
||||
hasPassword: true,
|
||||
avatarUrl: null,
|
||||
features: [],
|
||||
},
|
||||
}));
|
||||
const store = createStore({ fetch, request });
|
||||
|
||||
await expect(store.fetchSession()).rejects.toThrow(
|
||||
'Realtime user profile does not match auth session'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
updateUserProfileMutation,
|
||||
uploadAvatarMutation,
|
||||
} from '@affine/graphql';
|
||||
import type { CurrentUserProfileSnapshot } from '@affine/realtime';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { GlobalState, NbstoreService } from '../../storage';
|
||||
@@ -14,19 +15,12 @@ import type { FetchService } from '../services/fetch';
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
import type { ServerService } from '../services/server';
|
||||
|
||||
export interface AccountProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
hasPassword: boolean;
|
||||
export interface AccountProfile extends CurrentUserProfileSnapshot {
|
||||
authMethods?: {
|
||||
password: { bound: boolean };
|
||||
oauth: { bound: boolean; providers: string[] };
|
||||
passkey: { bound: boolean; count: number };
|
||||
};
|
||||
avatarUrl: string | null;
|
||||
emailVerified: string | null;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
export class AuthStore extends Store {
|
||||
@@ -68,9 +62,10 @@ export class AuthStore extends Store {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
hasPassword: user.hasPassword,
|
||||
avatarUrl: user.avatarUrl,
|
||||
emailVerified: user.emailVerified ? 'true' : null,
|
||||
emailVerified: user.emailVerified,
|
||||
features: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -85,24 +80,30 @@ export class AuthStore extends Store {
|
||||
}
|
||||
|
||||
async fetchSession() {
|
||||
const { user } = await this.fetchService
|
||||
const session = await this.fetchAuthSession();
|
||||
if (!session.user) return { user: null };
|
||||
|
||||
const { user } = await this.nbstoreService.realtime.request(
|
||||
'user.profile.get',
|
||||
{}
|
||||
);
|
||||
if (!user || user.id !== session.user.id) {
|
||||
throw new Error('Realtime user profile does not match auth session');
|
||||
}
|
||||
const authMethods = await this.fetchAuthMethods();
|
||||
return { user: { ...user, authMethods } };
|
||||
}
|
||||
|
||||
private async fetchAuthSession(): Promise<{ user: { id: string } | null }> {
|
||||
return await this.fetchService
|
||||
.fetch('/api/auth/session', { cache: 'no-store' })
|
||||
.then(res => res.json());
|
||||
const authMethods = user
|
||||
? await this.fetchService
|
||||
.fetch('/api/auth/methods')
|
||||
.then(res => (res.ok ? res.json() : undefined))
|
||||
: undefined;
|
||||
return {
|
||||
user: user
|
||||
? {
|
||||
...user,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
authMethods,
|
||||
emailVerified: user.emailVerified ? 'true' : null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchAuthMethods() {
|
||||
return await this.fetchService
|
||||
.fetch('/api/auth/methods')
|
||||
.then(res => (res.ok ? res.json() : undefined));
|
||||
}
|
||||
|
||||
async signInMagicLink(email: string, token: string) {
|
||||
|
||||
Reference in New Issue
Block a user