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:
DarkSky
2026-06-10 22:43:31 +08:00
committed by GitHub
parent 6faebcabd3
commit 07a08e6d4d
9 changed files with 392 additions and 65 deletions
@@ -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) {