mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 17:16:16 +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:
@@ -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