diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 53d8055427f15..40176aef4cc77 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -82,6 +82,7 @@ "prisma": "^5.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "semver": "^7.6.3", "ses": "^1.10.0", "socket.io": "^4.8.1", "stripe": "^17.4.0", @@ -104,6 +105,7 @@ "@types/node": "^20.17.10", "@types/nodemailer": "^6.4.17", "@types/on-headers": "^1.0.3", + "@types/semver": "^7.5.8", "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", "ava": "^6.2.0", diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index d6764ab9c203d..6ad38902504c7 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -35,6 +35,7 @@ import { SelfhostModule } from './core/selfhost'; import { StorageModule } from './core/storage'; import { SyncModule } from './core/sync'; import { UserModule } from './core/user'; +import { VersionModule } from './core/version'; import { WorkspaceModule } from './core/workspaces'; import { REGISTERED_PLUGINS } from './plugins'; import { ENABLED_PLUGINS } from './plugins/registry'; @@ -167,6 +168,7 @@ export function buildAppModule() { .useIf( config => config.flavor.graphql, ScheduleModule.forRoot(), + VersionModule, GqlModule, StorageModule, ServerConfigModule, diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 79345ef300d0e..93b5db6689023 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -593,4 +593,15 @@ export const USER_FRIENDLY_ERRORS = { type: 'bad_request', message: 'Captcha verification failed.', }, + // version errors + unsupported_client_version: { + type: 'action_forbidden', + args: { + clientVersion: 'string', + recommendedVersion: 'string', + action: 'string', + }, + message: ({ clientVersion, recommendedVersion, action }) => + `Unsupported client version: ${clientVersion}, please ${action} to ${recommendedVersion}.`, + }, } satisfies Record; diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index a17feb5b84ac5..426371d18bea6 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -591,6 +591,18 @@ export class CaptchaVerificationFailed extends UserFriendlyError { super('bad_request', 'captcha_verification_failed', message); } } +@ObjectType() +class UnsupportedClientVersionDataType { + @Field() clientVersion!: string + @Field() recommendedVersion!: string + @Field() action!: string +} + +export class UnsupportedClientVersion extends UserFriendlyError { + constructor(args: UnsupportedClientVersionDataType, message?: string | ((args: UnsupportedClientVersionDataType) => string)) { + super('action_forbidden', 'unsupported_client_version', message, args); + } +} export enum ErrorNames { INTERNAL_SERVER_ERROR, TOO_MANY_REQUEST, @@ -669,7 +681,8 @@ export enum ErrorNames { MAILER_SERVICE_IS_NOT_CONFIGURED, CANNOT_DELETE_ALL_ADMIN_ACCOUNT, CANNOT_DELETE_OWN_ACCOUNT, - CAPTCHA_VERIFICATION_FAILED + CAPTCHA_VERIFICATION_FAILED, + UNSUPPORTED_CLIENT_VERSION } registerEnumType(ErrorNames, { name: 'ErrorNames' @@ -678,5 +691,5 @@ registerEnumType(ErrorNames, { export const ErrorDataUnionType = createUnionType({ name: 'ErrorDataUnion', types: () => - [WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const, + [WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, UnsupportedClientVersionDataType] as const, }); diff --git a/packages/backend/server/src/base/guard/guard.ts b/packages/backend/server/src/base/guard/guard.ts index e747ee6f0aa44..28f7794d65d6b 100644 --- a/packages/backend/server/src/base/guard/guard.ts +++ b/packages/backend/server/src/base/guard/guard.ts @@ -18,14 +18,19 @@ export class BasicGuard implements CanActivate { async canActivate(context: ExecutionContext) { // get registered guard name - const providerName = this.reflector.get( + const providerName = this.reflector.get( BasicGuardSymbol, context.getHandler() ); - const provider = GUARD_PROVIDER[providerName as NamedGuards]; - if (provider) { - return await provider.canActivate(context); + if (Array.isArray(providerName) && providerName.length > 0) { + for (const name of providerName) { + const provider = GUARD_PROVIDER[name as NamedGuards]; + if (provider) { + const ret = await provider.canActivate(context); + if (!ret) return false; + } + } } return true; @@ -46,5 +51,5 @@ export class BasicGuard implements CanActivate { * } * ``` */ -export const UseNamedGuard = (name: NamedGuards) => +export const UseNamedGuard = (...name: NamedGuards[]) => applyDecorators(UseGuards(BasicGuard), SetMetadata(BasicGuardSymbol, name)); diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index dd954eeb9c812..9b95f0b3201bf 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -72,6 +72,7 @@ export class AuthController { } @Public() + @UseNamedGuard('version') @Post('/preflight') async preflight( @Body() params?: { email: string } @@ -103,7 +104,7 @@ export class AuthController { } @Public() - @UseNamedGuard('captcha') + @UseNamedGuard('version', 'captcha') @Post('/sign-in') @Header('content-type', 'application/json') async signIn( @@ -236,6 +237,7 @@ export class AuthController { } @Public() + @UseNamedGuard('version') @Post('/magic-link') async magicLinkSignIn( @Req() req: Request, diff --git a/packages/backend/server/src/core/version/config.ts b/packages/backend/server/src/core/version/config.ts new file mode 100644 index 0000000000000..3ea427cf5dcc1 --- /dev/null +++ b/packages/backend/server/src/core/version/config.ts @@ -0,0 +1,29 @@ +import { defineRuntimeConfig, ModuleConfig } from '../../base/config'; + +export interface VersionConfig { + enable: boolean; + allowedVersion: string; +} + +declare module '../../base/config' { + interface AppConfig { + version: ModuleConfig; + } +} + +declare module '../../base/guard' { + interface RegisterGuardName { + version: 'version'; + } +} + +defineRuntimeConfig('version', { + enable: { + desc: 'Check version of the app', + default: false, + }, + allowedVersion: { + desc: 'Allowed version range of the app that can access the server', + default: '>=0.0.1', + }, +}); diff --git a/packages/backend/server/src/core/version/guard.ts b/packages/backend/server/src/core/version/guard.ts new file mode 100644 index 0000000000000..916256a5973b4 --- /dev/null +++ b/packages/backend/server/src/core/version/guard.ts @@ -0,0 +1,40 @@ +import type { + CanActivate, + ExecutionContext, + OnModuleInit, +} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; + +import { + getRequestResponseFromContext, + GuardProvider, + Runtime, +} from '../../base'; +import { VersionService } from './service'; + +@Injectable() +export class VersionGuardProvider + extends GuardProvider + implements CanActivate, OnModuleInit +{ + name = 'version' as const; + + constructor( + private readonly runtime: Runtime, + private readonly version: VersionService + ) { + super(); + } + + async canActivate(context: ExecutionContext) { + if (!(await this.runtime.fetch('version/enable'))) { + return true; + } + + const { req } = getRequestResponseFromContext(context); + + const version = req.headers['x-affine-version']; + + return this.version.checkVersion(version); + } +} diff --git a/packages/backend/server/src/core/version/index.ts b/packages/backend/server/src/core/version/index.ts new file mode 100644 index 0000000000000..0ef83c14beebc --- /dev/null +++ b/packages/backend/server/src/core/version/index.ts @@ -0,0 +1,13 @@ +import './config'; + +import { Module } from '@nestjs/common'; + +import { VersionGuardProvider } from './guard'; +import { VersionService } from './service'; + +@Module({ + providers: [VersionService, VersionGuardProvider], +}) +export class VersionModule {} + +export type { VersionConfig } from './config'; diff --git a/packages/backend/server/src/core/version/service.ts b/packages/backend/server/src/core/version/service.ts new file mode 100644 index 0000000000000..66f3c82f4dcfe --- /dev/null +++ b/packages/backend/server/src/core/version/service.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert'; + +import { Injectable, Logger } from '@nestjs/common'; +import semver from 'semver'; + +import { Runtime, UnsupportedClientVersion } from '../../base'; + +@Injectable() +export class VersionService { + private readonly logger = new Logger(VersionService.name); + + constructor(private readonly runtime: Runtime) {} + + private async getRecommendedVersion(versionRange: string) { + try { + const range = new semver.Range(versionRange); + const versions = range.set + .flat() + .map(c => c.semver) + .toSorted((a, b) => semver.rcompare(a, b)); + return versions[0]?.toString(); + } catch { + return semver.valid(semver.coerce(versionRange)); + } + } + + async checkVersion(clientVersion?: any) { + const allowedVersion = await this.runtime.fetch('version/allowedVersion'); + const recommendedVersion = await this.getRecommendedVersion(allowedVersion); + if (!allowedVersion || !recommendedVersion) { + // ignore invalid allowed version config + return true; + } + + const parsedClientVersion = semver.valid(clientVersion); + const action = semver.lt(parsedClientVersion || '0.0.0', recommendedVersion) + ? 'upgrade' + : 'downgrade'; + assert( + typeof clientVersion === 'string' && clientVersion.length > 0, + new UnsupportedClientVersion({ + clientVersion: '[Not Provided]', + recommendedVersion, + action, + }) + ); + + if (parsedClientVersion) { + if (!semver.satisfies(parsedClientVersion, allowedVersion)) { + throw new UnsupportedClientVersion({ + clientVersion, + recommendedVersion, + action, + }); + } + return true; + } else { + if (clientVersion) { + this.logger.warn(`Invalid client version: ${clientVersion}`); + } + throw new UnsupportedClientVersion({ + clientVersion, + recommendedVersion, + action, + }); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts index f38eaa2eb0992..fd9c59fe5a962 100644 --- a/packages/backend/server/src/plugins/oauth/controller.ts +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -16,6 +16,7 @@ import { OauthAccountAlreadyConnected, OauthStateExpired, UnknownOauthProvider, + UseNamedGuard, } from '../../base'; import { AuthService, Public } from '../../core/auth'; import { UserService } from '../../core/user'; @@ -35,6 +36,7 @@ export class OAuthController { ) {} @Public() + @UseNamedGuard('version') @Post('/preflight') @HttpCode(HttpStatus.OK) async preflight( @@ -64,6 +66,7 @@ export class OAuthController { } @Public() + @UseNamedGuard('version') @Post('/callback') @HttpCode(HttpStatus.OK) async callback( diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 3ec9dee256c99..26611bae5fa0c 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -209,7 +209,7 @@ type EditorType { name: String! } -union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WrongSignInCredentialsDataType +union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WrongSignInCredentialsDataType enum ErrorNames { ACCESS_DENIED @@ -282,6 +282,7 @@ enum ErrorNames { TOO_MANY_REQUEST UNKNOWN_OAUTH_PROVIDER UNSPLASH_IS_NOT_CONFIGURED + UNSUPPORTED_CLIENT_VERSION UNSUPPORTED_SUBSCRIPTION_PLAN USER_AVATAR_NOT_FOUND USER_NOT_FOUND @@ -861,6 +862,12 @@ type UnknownOauthProviderDataType { name: String! } +type UnsupportedClientVersionDataType { + action: String! + clientVersion: String! + recommendedVersion: String! +} + type UnsupportedSubscriptionPlanDataType { plan: String! } diff --git a/packages/backend/server/tests/version.spec.ts b/packages/backend/server/tests/version.spec.ts new file mode 100644 index 0000000000000..0c5cf48ab10d9 --- /dev/null +++ b/packages/backend/server/tests/version.spec.ts @@ -0,0 +1,217 @@ +import '../src/core/version/config'; + +import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +import request from 'supertest'; + +import { AppModule } from '../src/app.module'; +import { Runtime, UseNamedGuard } from '../src/base'; +import { Public } from '../src/core/auth/guard'; +import { createTestingApp, initTestingDB } from './utils'; + +const test = ava as TestFn<{ + runtime: Runtime; + cookie: string; + app: INestApplication; +}>; + +@Public() +@Controller('/guarded') +class GuardedController { + @UseNamedGuard('version') + @Get('/test') + test() { + return 'test'; + } +} + +test.before(async t => { + const { app } = await createTestingApp({ + imports: [AppModule], + controllers: [GuardedController], + }); + + t.context.runtime = app.get(Runtime); + t.context.app = app; +}); + +test.beforeEach(async t => { + await initTestingDB(t.context.app.get(PrismaClient)); + // reset runtime + await t.context.runtime.loadDb('version/enable'); + await t.context.runtime.loadDb('version/allowedVersion'); + await t.context.runtime.set('version/enable', false); + await t.context.runtime.set('version/allowedVersion', '>=0.0.1'); +}); + +test.after.always(async t => { + await t.context.app.close(); +}); + +async function fetchWithVersion( + server: any, + version: string | undefined, + status: number +) { + let req = request(server).get('/guarded/test'); + if (version) { + req = req.set({ 'x-affine-version': version }); + } + const res = await req.expect(status); + if (res.body.message) { + throw new Error(res.body.message); + } + return res; +} + +test('should be able to prevent requests if version outdated', async t => { + const { app, runtime } = t.context; + + { + await runtime.set('version/enable', false); + await t.notThrowsAsync( + fetchWithVersion(app.getHttpServer(), undefined, HttpStatus.OK), + 'should not check version if disabled' + ); + } + + { + await runtime.set('version/enable', true); + await t.throwsAsync( + fetchWithVersion(app.getHttpServer(), undefined, HttpStatus.FORBIDDEN), + { + message: + 'Unsupported client version: [Not Provided], please upgrade to 0.0.1.', + }, + 'should check version exists' + ); + await t.throwsAsync( + fetchWithVersion( + app.getHttpServer(), + 'not_a_version', + HttpStatus.FORBIDDEN + ), + { + message: + 'Unsupported client version: not_a_version, please upgrade to 0.0.1.', + }, + 'should check version exists' + ); + await t.notThrowsAsync( + fetchWithVersion(app.getHttpServer(), '0.0.1', HttpStatus.OK), + 'should check version exists' + ); + } + + { + await runtime.set('version/allowedVersion', 'unknownVersion'); + await t.notThrowsAsync( + fetchWithVersion(app.getHttpServer(), undefined, HttpStatus.OK), + 'should not check version if invalid allowedVersion provided' + ); + await t.notThrowsAsync( + fetchWithVersion(app.getHttpServer(), '0.0.1', HttpStatus.OK), + 'should not check version if invalid allowedVersion provided' + ); + + await runtime.set('version/allowedVersion', '0.0.1'); + await t.throwsAsync( + fetchWithVersion(app.getHttpServer(), '0.0.0', HttpStatus.FORBIDDEN), + { + message: 'Unsupported client version: 0.0.0, please upgrade to 0.0.1.', + }, + 'should reject version if valid allowedVersion provided' + ); + + await runtime.set( + 'version/allowedVersion', + '0.17.5 || >=0.18.0-nightly || >=0.18.0' + ); + await t.notThrowsAsync( + fetchWithVersion(app.getHttpServer(), '0.17.5', HttpStatus.OK), + 'should pass version if version satisfies allowedVersion' + ); + await t.throwsAsync( + fetchWithVersion(app.getHttpServer(), '0.17.4', HttpStatus.FORBIDDEN), + { + message: + 'Unsupported client version: 0.17.4, please upgrade to 0.18.0.', + }, + 'should reject version if valid allowedVersion provided' + ); + await t.throwsAsync( + fetchWithVersion( + app.getHttpServer(), + '0.17.6-nightly-f0d99f4', + HttpStatus.FORBIDDEN + ), + { + message: + 'Unsupported client version: 0.17.6-nightly-f0d99f4, please upgrade to 0.18.0.', + }, + 'should reject version if valid allowedVersion provided' + ); + await t.notThrowsAsync( + fetchWithVersion( + app.getHttpServer(), + '0.18.0-nightly-cc9b38c', + HttpStatus.OK + ), + 'should pass version if version satisfies allowedVersion' + ); + await t.notThrowsAsync( + fetchWithVersion(app.getHttpServer(), '0.18.1', HttpStatus.OK), + 'should pass version if version satisfies allowedVersion' + ); + } + + { + await runtime.set( + 'version/allowedVersion', + '>=0.0.1 <=0.1.2 || ^0.2.0-nightly <0.2.0 || 0.3.0' + ); + + await t.notThrowsAsync( + fetchWithVersion(app.getHttpServer(), '0.0.1', HttpStatus.OK), + 'should pass version if version satisfies allowedVersion' + ); + await t.notThrowsAsync( + fetchWithVersion(app.getHttpServer(), '0.1.2', HttpStatus.OK), + 'should pass version if version satisfies allowedVersion' + ); + await t.throwsAsync( + fetchWithVersion(app.getHttpServer(), '0.1.3', HttpStatus.FORBIDDEN), + { + message: 'Unsupported client version: 0.1.3, please upgrade to 0.3.0.', + }, + 'should reject version if valid allowedVersion provided' + ); + + await t.notThrowsAsync( + fetchWithVersion( + app.getHttpServer(), + '0.2.0-nightly-cc9b38c', + HttpStatus.OK + ), + 'should pass version if version satisfies allowedVersion' + ); + + await t.throwsAsync( + fetchWithVersion(app.getHttpServer(), '0.2.0', HttpStatus.FORBIDDEN), + { + message: 'Unsupported client version: 0.2.0, please upgrade to 0.3.0.', + }, + 'should reject version if valid allowedVersion provided' + ); + + await t.throwsAsync( + fetchWithVersion(app.getHttpServer(), '0.3.1', HttpStatus.FORBIDDEN), + { + message: + 'Unsupported client version: 0.3.1, please downgrade to 0.3.0.', + }, + 'should reject version if valid allowedVersion provided' + ); + } +}); diff --git a/packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx b/packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx index 30b00e1c5859f..2d04e1c68a858 100644 --- a/packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx +++ b/packages/frontend/core/src/components/sign-in/sign-in-with-password.tsx @@ -12,11 +12,16 @@ import { ServerService, } from '@affine/core/modules/cloud'; import { Unreachable } from '@affine/env/constant'; -import { ServerDeploymentType } from '@affine/graphql'; +import { + ErrorNames, + ServerDeploymentType, + UserFriendlyError, +} from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import type { SignInState } from '.'; import { Captcha } from './captcha'; @@ -59,6 +64,7 @@ export const SignInWithPasswordStep = ({ const [isLoading, setIsLoading] = useState(false); const loginStatus = useLiveData(authService.session.status$); + const nav = useNavigate(); useEffect(() => { if (loginStatus === 'authenticated') { @@ -84,6 +90,14 @@ export const SignInWithPasswordStep = ({ }); } catch (err) { console.error(err); + const userFriendlyError = UserFriendlyError.fromAnyError(err); + if (userFriendlyError.name === ErrorNames.UNSUPPORTED_CLIENT_VERSION) { + const { action } = userFriendlyError.args; + nav( + `/sign-in?error=${encodeURIComponent(userFriendlyError.message)}&action=${encodeURIComponent(action as string)}` + ); + return; + } setPasswordError(true); } finally { setIsLoading(false); @@ -97,6 +111,7 @@ export const SignInWithPasswordStep = ({ email, password, challenge, + nav, ]); const sendMagicLink = useCallback(() => { diff --git a/packages/frontend/core/src/components/sign-in/sign-in.tsx b/packages/frontend/core/src/components/sign-in/sign-in.tsx index 57d13924aa1cb..5dc5828eba901 100644 --- a/packages/frontend/core/src/components/sign-in/sign-in.tsx +++ b/packages/frontend/core/src/components/sign-in/sign-in.tsx @@ -4,7 +4,11 @@ import { OAuth } from '@affine/core/components/affine/auth/oauth'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { AuthService, ServerService } from '@affine/core/modules/cloud'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; -import { ServerDeploymentType } from '@affine/graphql'; +import { + ErrorNames, + ServerDeploymentType, + UserFriendlyError, +} from '@affine/graphql'; import { Trans, useI18n } from '@affine/i18n'; import { ArrowRightBigIcon, PublishIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; @@ -120,14 +124,41 @@ export const SignInStep = ({ } catch (err) { console.error(err); - // TODO(@eyhn): better error handling - notify.error({ - title: 'Failed to send email. Please try again.', - }); + const userFriendlyError = UserFriendlyError.fromAnyError(err); + if (userFriendlyError.name === ErrorNames.UNSUPPORTED_CLIENT_VERSION) { + const { action } = userFriendlyError.args; + notify.error({ + title: t['com.affine.minimum-client.title'](), + message: + t[ + `com.affine.minimum-client.${action === 'upgrade' ? 'outdated' : 'advanced'}.message` + ](), + action: { + label: + t[ + `com.affine.minimum-client.${action === 'upgrade' ? 'outdated' : 'advanced'}.button` + ](), + onClick: () => + window.open( + BUILD_CONFIG.downloadUrl, + '_blank', + 'noreferrer noopener' + ), + buttonProps: { + variant: 'primary', + }, + }, + }); + } else { + // TODO(@eyhn): better error handling + notify.error({ + title: 'Failed to send email. Please try again.', + }); + } } setIsMutating(false); - }, [authService, changeState, email]); + }, [authService, changeState, email, t]); const onAddSelfhosted = useCallback(() => { changeState(prev => ({ diff --git a/packages/frontend/core/src/desktop/pages/auth/sign-in.tsx b/packages/frontend/core/src/desktop/pages/auth/sign-in.tsx index f58c25e2f6e92..64675076d86db 100644 --- a/packages/frontend/core/src/desktop/pages/auth/sign-in.tsx +++ b/packages/frontend/core/src/desktop/pages/auth/sign-in.tsx @@ -1,4 +1,4 @@ -import { notify } from '@affine/component'; +import { notify, useConfirmModal } from '@affine/component'; import { AffineOtherPageLayout } from '@affine/component/affine-other-page-layout'; import { SignInPageContainer } from '@affine/component/auth-components'; import { SignInPanel } from '@affine/core/components/sign-in'; @@ -24,8 +24,10 @@ export const SignIn = ({ const navigate = useNavigate(); const { jumpToIndex } = useNavigateHelper(); const [searchParams] = useSearchParams(); + const { openConfirmModal } = useConfirmModal(); const redirectUrl = redirectUrlFromProps ?? searchParams.get('redirect_uri'); const error = searchParams.get('error'); + const action = searchParams.get('action'); useEffect(() => { if (error) { @@ -36,6 +38,28 @@ export const SignIn = ({ } }, [error, t]); + useEffect(() => { + if (action === 'upgrade' || action === 'downgrade') { + openConfirmModal({ + title: t['com.affine.minimum-client.title'](), + description: + t[ + `com.affine.minimum-client.${action === 'upgrade' ? 'outdated' : 'advanced'}.message` + ](), + confirmText: + t[ + `com.affine.minimum-client.${action === 'upgrade' ? 'outdated' : 'advanced'}.button` + ](), + onConfirm: () => + window.open( + BUILD_CONFIG.downloadUrl, + '_blank', + 'noreferrer noopener' + ), + }); + } + }, [action, jumpToIndex, openConfirmModal, searchParams, t]); + const handleClose = () => { if (session.status$.value === 'authenticated' && redirectUrl) { navigate(redirectUrl, { diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts index b909e3885c0fe..fd65b81c9abef 100644 --- a/packages/frontend/core/src/modules/cloud/stores/auth.ts +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -98,10 +98,6 @@ export class AuthStore extends Store { }, }); - if (!res.ok) { - throw new Error(`Failed to check user by email: ${email}`); - } - const data = (await res.json()) as { registered: boolean; hasPassword: boolean; diff --git a/packages/frontend/core/src/modules/desktop-api/service/desktop-api.ts b/packages/frontend/core/src/modules/desktop-api/service/desktop-api.ts index 9f2beb7783193..1d00ca2273436 100644 --- a/packages/frontend/core/src/modules/desktop-api/service/desktop-api.ts +++ b/packages/frontend/core/src/modules/desktop-api/service/desktop-api.ts @@ -1,4 +1,5 @@ import { notify } from '@affine/component'; +import { ErrorNames, UserFriendlyError } from '@affine/graphql'; import { I18n } from '@affine/i18n'; import { init, @@ -173,10 +174,37 @@ export class DesktopApiService extends Service { } } })().catch(e => { - notify.error({ - title: I18n['com.affine.auth.toast.title.failed'](), - message: (e as any).message, - }); + const userFriendlyError = UserFriendlyError.fromAnyError(e); + if (userFriendlyError.name === ErrorNames.UNSUPPORTED_CLIENT_VERSION) { + const { action } = userFriendlyError.args; + notify.error({ + title: I18n['com.affine.minimum-client.title'](), + message: + I18n[ + `com.affine.minimum-client.${action === 'upgrade' ? 'outdated' : 'advanced'}.message` + ](), + action: { + label: + I18n[ + `com.affine.minimum-client.${action === 'upgrade' ? 'outdated' : 'advanced'}.button` + ](), + onClick: () => + window.open( + BUILD_CONFIG.downloadUrl, + '_blank', + 'noreferrer noopener' + ), + buttonProps: { + variant: 'primary', + }, + }, + }); + } else { + notify.error({ + title: I18n['com.affine.auth.toast.title.failed'](), + message: (e as any).message, + }); + } }); }); } diff --git a/packages/frontend/graphql/src/error.ts b/packages/frontend/graphql/src/error.ts index c10623454abd5..6ecde21b71318 100644 --- a/packages/frontend/graphql/src/error.ts +++ b/packages/frontend/graphql/src/error.ts @@ -8,7 +8,7 @@ export interface UserFriendlyErrorResponse { type: string; name: ErrorNames; message: string; - args?: any; + data?: any; stacktrace?: string; } @@ -21,7 +21,7 @@ export class UserFriendlyError readonly type = this.response.type; override readonly name = this.response.name; override readonly message = this.response.message; - readonly args = this.response.args; + readonly args = this.response.data; readonly stacktrace = this.response.stacktrace; static fromAnyError(response: any) { diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index cc2b381f7ddee..8d2ad27fc7a4f 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -285,6 +285,7 @@ export type ErrorDataUnion = | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType + | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WrongSignInCredentialsDataType; @@ -360,6 +361,7 @@ export enum ErrorNames { TOO_MANY_REQUEST = 'TOO_MANY_REQUEST', UNKNOWN_OAUTH_PROVIDER = 'UNKNOWN_OAUTH_PROVIDER', UNSPLASH_IS_NOT_CONFIGURED = 'UNSPLASH_IS_NOT_CONFIGURED', + UNSUPPORTED_CLIENT_VERSION = 'UNSUPPORTED_CLIENT_VERSION', UNSUPPORTED_SUBSCRIPTION_PLAN = 'UNSUPPORTED_SUBSCRIPTION_PLAN', USER_AVATAR_NOT_FOUND = 'USER_AVATAR_NOT_FOUND', USER_NOT_FOUND = 'USER_NOT_FOUND', @@ -1207,6 +1209,13 @@ export interface UnknownOauthProviderDataType { name: Scalars['String']['output']; } +export interface UnsupportedClientVersionDataType { + __typename?: 'UnsupportedClientVersionDataType'; + action: Scalars['String']['output']; + clientVersion: Scalars['String']['output']; + recommendedVersion: Scalars['String']['output']; +} + export interface UnsupportedSubscriptionPlanDataType { __typename?: 'UnsupportedSubscriptionPlanDataType'; plan: Scalars['String']['output']; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 1c81c85c44084..87281f22cbd75 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,5 +1,5 @@ { - "ar": 68, + "ar": 67, "ca": 5, "da": 5, "de": 26, @@ -15,10 +15,10 @@ "ja": 90, "ko": 72, "pl": 0, - "pt-BR": 78, + "pt-BR": 77, "ru": 66, "sv-SE": 4, "ur": 2, - "zh-Hans": 91, + "zh-Hans": 90, "zh-Hant": 90 } \ No newline at end of file diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index f112ce394a980..e3b03ab0e252f 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1614,5 +1614,10 @@ "com.affine.payment.sync-paused.member.both.description": "This workspace has exceeded both storage and member limits, causing synchronization to pause. Please contact your workspace owner to address these limits and resume syncing.", "com.affine.payment.sync-paused.member.storage.description": "This workspace has exceeded its storage limit and synchronization has been paused. Please contact your workspace owner to either reduce storage usage or upgrade the plan to resume syncing.", "com.affine.payment.sync-paused.member.member.description": "This workspace has reached its maximum member capacity and synchronization has been paused. Please contact your workspace owner to either adjust team membership or upgrade the plan to resume syncing.", - "com.affine.payment.sync-paused.member.member.confirm": "Got It" + "com.affine.payment.sync-paused.member.member.confirm": "Got It", + "com.affine.minimum-client.title": "Client is outdated", + "com.affine.minimum-client.outdated.message": "This client is outdated, for the security of your data, please update the client, before accessing the data, or visit our web app.", + "com.affine.minimum-client.advanced.message": "This client app is not supported by the server yet, for the security of your data, please download the matching client, before accessing the data, or visit our web app.", + "com.affine.minimum-client.outdated.button": "Update client", + "com.affine.minimum-client.advanced.button": "Download client" } diff --git a/yarn.lock b/yarn.lock index 08b98ec18ebed..034623b486eed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -779,6 +779,7 @@ __metadata: "@types/node": "npm:^20.17.10" "@types/nodemailer": "npm:^6.4.17" "@types/on-headers": "npm:^1.0.3" + "@types/semver": "npm:^7.5.8" "@types/sinon": "npm:^17.0.3" "@types/supertest": "npm:^6.0.2" ava: "npm:^6.2.0" @@ -809,6 +810,7 @@ __metadata: prisma: "npm:^5.22.0" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" + semver: "npm:^7.6.3" ses: "npm:^1.10.0" sinon: "npm:^19.0.2" socket.io: "npm:^4.8.1" @@ -14904,6 +14906,13 @@ __metadata: languageName: node linkType: hard +"@types/semver@npm:^7.5.8": + version: 7.5.8 + resolution: "@types/semver@npm:7.5.8" + checksum: 10/3496808818ddb36deabfe4974fd343a78101fa242c4690044ccdc3b95dcf8785b494f5d628f2f47f38a702f8db9c53c67f47d7818f2be1b79f2efb09692e1178 + languageName: node + linkType: hard + "@types/send@npm:*": version: 0.17.4 resolution: "@types/send@npm:0.17.4"