From 3e547ce4cc4df905ad033ea58febf7fca1f9ac26 Mon Sep 17 00:00:00 2001 From: EYHN Date: Mon, 25 Mar 2024 13:52:08 +0000 Subject: [PATCH 01/12] fix(core): hidden modals when workspace fallback (#6301) --- packages/frontend/core/src/pages/workspace/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/frontend/core/src/pages/workspace/index.tsx b/packages/frontend/core/src/pages/workspace/index.tsx index a80f4e9d9e..81b479a396 100644 --- a/packages/frontend/core/src/pages/workspace/index.tsx +++ b/packages/frontend/core/src/pages/workspace/index.tsx @@ -18,10 +18,7 @@ import { WorkspaceLayout } from '../../layouts/workspace-layout'; import { RightSidebarContainer } from '../../modules/right-sidebar'; import { WorkbenchRoot } from '../../modules/workbench'; import { CurrentWorkspaceService } from '../../modules/workspace/current-workspace'; -import { - AllWorkspaceModals, - CurrentWorkspaceModals, -} from '../../providers/modal-provider'; +import { AllWorkspaceModals } from '../../providers/modal-provider'; import { performanceRenderLogger } from '../../shared'; import { PageNotFound } from '../404'; @@ -92,7 +89,6 @@ export const Component = (): ReactElement => { - ); } From 00092c9955fd65ea77eef0c9d9c1d6189da38138 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Mon, 25 Mar 2024 15:57:22 +0000 Subject: [PATCH 02/12] fix(electron): fix electron build (#6305) --- packages/frontend/electron/package.json | 2 +- packages/frontend/electron/scripts/common.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/electron/package.json b/packages/frontend/electron/package.json index a9fc89d619..ca19e81f42 100644 --- a/packages/frontend/electron/package.json +++ b/packages/frontend/electron/package.json @@ -61,6 +61,7 @@ "jotai-devtools": "^0.8.0", "lodash-es": "^4.17.21", "mixpanel-browser": "^2.49.0", + "nanoid": "^5.0.6", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", @@ -79,7 +80,6 @@ "async-call-rpc": "^6.4.0", "electron-updater": "^6.1.9", "link-preview-js": "^3.0.5", - "nanoid": "^5.0.6", "yjs": "^13.6.12" }, "build": { diff --git a/packages/frontend/electron/scripts/common.ts b/packages/frontend/electron/scripts/common.ts index 25a8f96560..a62d6ddb7a 100644 --- a/packages/frontend/electron/scripts/common.ts +++ b/packages/frontend/electron/scripts/common.ts @@ -39,7 +39,7 @@ export const config = (): BuildOptions => { bundle: true, target: `node${NODE_MAJOR_VERSION}`, platform: 'node', - external: ['electron', 'electron-updater', 'yjs', 'semver', 'tinykeys'], + external: ['electron', 'electron-updater', 'yjs', 'semver'], format: 'cjs', loader: { '.node': 'copy', From 1c9d89983197a33b04dafdfbfd296e5e20f0e7b1 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Tue, 26 Mar 2024 02:04:13 +0000 Subject: [PATCH 03/12] fix: runtime issue for electron app (#6306) Looks like we need to be careful to share common libraries between electron (nodejs) & web ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/7e568e47-2d61-45c8-8a1e-b933b63fd1a9.png) --- packages/common/infra/package.json | 2 ++ packages/frontend/electron/src/helper/db/base-db-adapter.ts | 2 +- packages/frontend/electron/src/helper/db/migration.ts | 2 +- packages/frontend/electron/src/helper/dialog/dialog.ts | 2 +- .../frontend/electron/src/main/config-storage/persist.ts | 5 ++++- packages/frontend/electron/tsconfig.json | 5 ++++- 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/common/infra/package.json b/packages/common/infra/package.json index 2a7432dda9..aedf824daf 100644 --- a/packages/common/infra/package.json +++ b/packages/common/infra/package.json @@ -3,6 +3,8 @@ "type": "module", "private": true, "exports": { + "./blocksuite": "./src/blocksuite/index.ts", + "./app-config-storage": "./src/app-config-storage.ts", ".": "./src/index.ts" }, "dependencies": { diff --git a/packages/frontend/electron/src/helper/db/base-db-adapter.ts b/packages/frontend/electron/src/helper/db/base-db-adapter.ts index 4f7763d292..a9e0aa1573 100644 --- a/packages/frontend/electron/src/helper/db/base-db-adapter.ts +++ b/packages/frontend/electron/src/helper/db/base-db-adapter.ts @@ -1,6 +1,6 @@ import type { InsertRow } from '@affine/native'; import { SqliteConnection, ValidationResult } from '@affine/native'; -import { WorkspaceVersion } from '@toeverything/infra'; +import { WorkspaceVersion } from '@toeverything/infra/blocksuite'; import { applyGuidCompatibilityFix, migrateToLatest } from '../db/migration'; import { logger } from '../logger'; diff --git a/packages/frontend/electron/src/helper/db/migration.ts b/packages/frontend/electron/src/helper/db/migration.ts index 110502611d..dc1da3519f 100644 --- a/packages/frontend/electron/src/helper/db/migration.ts +++ b/packages/frontend/electron/src/helper/db/migration.ts @@ -8,7 +8,7 @@ import { migrateGuidCompatibility, migrateToSubdoc, WorkspaceVersion, -} from '@toeverything/infra'; +} from '@toeverything/infra/blocksuite'; import fs from 'fs-extra'; import { nanoid } from 'nanoid'; import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs'; diff --git a/packages/frontend/electron/src/helper/dialog/dialog.ts b/packages/frontend/electron/src/helper/dialog/dialog.ts index 3ea7be1186..c532556d7b 100644 --- a/packages/frontend/electron/src/helper/dialog/dialog.ts +++ b/packages/frontend/electron/src/helper/dialog/dialog.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { ValidationResult } from '@affine/native'; -import { WorkspaceVersion } from '@toeverything/infra'; +import { WorkspaceVersion } from '@toeverything/infra/blocksuite'; import fs from 'fs-extra'; import { nanoid } from 'nanoid'; diff --git a/packages/frontend/electron/src/main/config-storage/persist.ts b/packages/frontend/electron/src/main/config-storage/persist.ts index e1c1ab7a55..78fe9bfb5b 100644 --- a/packages/frontend/electron/src/main/config-storage/persist.ts +++ b/packages/frontend/electron/src/main/config-storage/persist.ts @@ -1,7 +1,10 @@ import fs from 'node:fs'; import path from 'node:path'; -import { AppConfigStorage, defaultAppConfig } from '@toeverything/infra'; +import { + AppConfigStorage, + defaultAppConfig, +} from '@toeverything/infra/app-config-storage'; import { app } from 'electron'; const FILENAME = 'config.json'; diff --git a/packages/frontend/electron/tsconfig.json b/packages/frontend/electron/tsconfig.json index 55a2d82b93..1a98e1e52b 100644 --- a/packages/frontend/electron/tsconfig.json +++ b/packages/frontend/electron/tsconfig.json @@ -11,7 +11,10 @@ "outDir": "lib", "moduleResolution": "node", "resolveJsonModule": true, - "noImplicitOverride": true + "noImplicitOverride": true, + "paths": { + "@toeverything/infra/*": ["../../common/infra/src/*"] + } }, "include": ["./src"], "exclude": ["renderer", "node_modules", "lib", "dist", "**/__tests__/**/*"], From 1a1af83375d1e75ab0201c3d21508a8a4a36ef17 Mon Sep 17 00:00:00 2001 From: forehalo Date: Tue, 26 Mar 2024 02:24:17 +0000 Subject: [PATCH 04/12] test(server): auth tests (#6135) --- .../server/src/core/auth/controller.ts | 28 +- .../backend/server/src/core/auth/index.ts | 4 +- .../backend/server/src/core/auth/resolver.ts | 6 +- .../backend/server/src/core/auth/service.ts | 76 ++-- .../backend/server/src/core/auth/token.ts | 12 + .../server/src/fundamentals/config/def.ts | 6 - .../server/src/fundamentals/prisma/index.ts | 8 +- .../server/src/fundamentals/prisma/service.ts | 5 +- .../server/src/plugins/oauth/controller.ts | 2 +- .../server/src/plugins/oauth/register.ts | 2 +- .../server/tests/auth/controller.spec.ts | 164 +++++++++ .../backend/server/tests/auth/guard.spec.ts | 131 +++++++ .../backend/server/tests/auth/service.spec.ts | 219 +++++++++++ .../backend/server/tests/auth/token.spec.ts | 93 +++++ .../server/tests/oauth/controller.spec.ts | 345 ++++++++++++++++++ packages/backend/server/tests/utils/user.ts | 29 +- packages/backend/server/tests/utils/utils.ts | 1 + .../server/tests/workspace-invite.e2e.ts | 11 +- packages/frontend/templates/templates.gen.ts | 12 +- 19 files changed, 1058 insertions(+), 96 deletions(-) create mode 100644 packages/backend/server/tests/auth/controller.spec.ts create mode 100644 packages/backend/server/tests/auth/guard.spec.ts create mode 100644 packages/backend/server/tests/auth/service.spec.ts create mode 100644 packages/backend/server/tests/auth/token.spec.ts create mode 100644 packages/backend/server/tests/oauth/controller.spec.ts diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index 0cce8cd4d8..96980e769e 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -6,6 +6,7 @@ import { Controller, Get, Header, + HttpStatus, Post, Query, Req, @@ -13,11 +14,7 @@ import { } from '@nestjs/common'; import type { Request, Response } from 'express'; -import { - Config, - PaymentRequiredException, - URLHelper, -} from '../../fundamentals'; +import { PaymentRequiredException, URLHelper } from '../../fundamentals'; import { UserService } from '../user'; import { validators } from '../utils/validators'; import { CurrentUser } from './current-user'; @@ -33,7 +30,6 @@ class SignInCredential { @Controller('/api/auth') export class AuthController { constructor( - private readonly config: Config, private readonly url: URLHelper, private readonly auth: AuthService, private readonly user: UserService, @@ -64,7 +60,7 @@ export class AuthController { ); await this.auth.setCookie(req, res, user); - res.send(user); + res.status(HttpStatus.OK).send(user); } else { // send email magic link const user = await this.user.findUserByEmail(credential.email); @@ -77,7 +73,7 @@ export class AuthController { throw new Error('Failed to send sign-in email.'); } - res.send({ + res.status(HttpStatus.OK).send({ email: credential.email, }); } @@ -162,22 +158,6 @@ export class AuthController { return this.url.safeRedirect(res, redirectUri); } - @Get('/authorize') - async authorize( - @CurrentUser() user: CurrentUser, - @Query('redirect_uri') redirect_uri?: string - ) { - const session = await this.auth.createUserSession( - user, - undefined, - this.config.auth.accessToken.ttl - ); - - this.url.link(redirect_uri ?? '/open-app/redirect', { - token: session.sessionId, - }); - } - @Public() @Get('/session') async currentSessionUser(@CurrentUser() user?: CurrentUser) { diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index b557ba65cc..318075f745 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -5,7 +5,7 @@ import { UserModule } from '../user'; import { AuthController } from './controller'; import { AuthResolver } from './resolver'; import { AuthService } from './service'; -import { TokenService } from './token'; +import { TokenService, TokenType } from './token'; @Module({ imports: [FeatureModule, UserModule], @@ -17,5 +17,5 @@ export class AuthModule {} export * from './guard'; export { ClientTokenType } from './resolver'; -export { AuthService }; +export { AuthService, TokenService, TokenType }; export * from './current-user'; diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index 58969d0b7b..1869ce22d8 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -17,6 +17,7 @@ import { import type { Request, Response } from 'express'; import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals'; +import { UserService } from '../user'; import { UserType } from '../user/types'; import { validators } from '../utils/validators'; import { CurrentUser } from './current-user'; @@ -48,6 +49,7 @@ export class AuthResolver { constructor( private readonly config: Config, private readonly auth: AuthService, + private readonly user: UserService, private readonly token: TokenService ) {} @@ -165,7 +167,7 @@ export class AuthResolver { throw new ForbiddenException('Invalid token'); } - await this.auth.changePassword(user.email, newPassword); + await this.auth.changePassword(user.id, newPassword); return user; } @@ -319,7 +321,7 @@ export class AuthResolver { throw new ForbiddenException('Invalid token'); } - const hasRegistered = await this.auth.getUserByEmail(email); + const hasRegistered = await this.user.findUserByEmail(email); if (hasRegistered) { if (hasRegistered.id !== user.id) { diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 75246f4e77..6fe0c6082b 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Injectable, NotAcceptableException, - NotFoundException, OnApplicationBootstrap, } from '@nestjs/common'; import type { User } from '@prisma/client'; @@ -10,30 +9,32 @@ import { PrismaClient } from '@prisma/client'; import type { CookieOptions, Request, Response } from 'express'; import { assign, omit } from 'lodash-es'; -import { - Config, - CryptoHelper, - MailService, - SessionCache, -} from '../../fundamentals'; +import { Config, CryptoHelper, MailService } from '../../fundamentals'; import { FeatureManagementService } from '../features/management'; import { UserService } from '../user/service'; import type { CurrentUser } from './current-user'; export function parseAuthUserSeqNum(value: any) { + let seq: number = 0; switch (typeof value) { case 'number': { - return value; + seq = value; + break; } case 'string': { - value = Number.parseInt(value); - return Number.isNaN(value) ? 0 : value; + const result = value.match(/^([\d{0, 10}])$/); + if (result?.[1]) { + seq = Number(result[1]); + } + break; } default: { - return 0; + seq = 0; } } + + return Math.max(0, seq); } export function sessionUser( @@ -57,7 +58,6 @@ export class AuthService implements OnApplicationBootstrap { sameSite: 'lax', httpOnly: true, path: '/', - domain: this.config.host, secure: this.config.https, }; static readonly sessionCookieName = 'sid'; @@ -69,8 +69,7 @@ export class AuthService implements OnApplicationBootstrap { private readonly mailer: MailService, private readonly feature: FeatureManagementService, private readonly user: UserService, - private readonly crypto: CryptoHelper, - private readonly cache: SessionCache + private readonly crypto: CryptoHelper ) {} async onApplicationBootstrap() { @@ -90,7 +89,7 @@ export class AuthService implements OnApplicationBootstrap { email: string, password: string ): Promise { - const user = await this.getUserByEmail(email); + const user = await this.user.findUserByEmail(email); if (user) { throw new BadRequestException('Email was taken'); @@ -111,12 +110,12 @@ export class AuthService implements OnApplicationBootstrap { const user = await this.user.findUserWithHashedPasswordByEmail(email); if (!user) { - throw new NotFoundException('User Not Found'); + throw new NotAcceptableException('Invalid sign in credentials'); } if (!user.password) { throw new NotAcceptableException( - 'User Password is not set. Should login throw email link.' + 'User Password is not set. Should login through email link.' ); } @@ -126,28 +125,12 @@ export class AuthService implements OnApplicationBootstrap { ); if (!passwordMatches) { - throw new NotAcceptableException('Incorrect Password'); + throw new NotAcceptableException('Invalid sign in credentials'); } return sessionUser(user); } - async getUserWithCache(token: string, seq = 0) { - const cacheKey = `session:${token}:${seq}`; - let user = await this.cache.get(cacheKey); - if (user) { - return user; - } - - user = await this.getUser(token, seq); - - if (user) { - await this.cache.set(cacheKey, user); - } - - return user; - } - async getUser(token: string, seq = 0): Promise { const session = await this.getSession(token); @@ -198,7 +181,16 @@ export class AuthService implements OnApplicationBootstrap { // Session // | { user: LimitedUser { email, avatarUrl }, expired: true } // | { user: User, expired: false } - return users.map(sessionUser); + return session.userSessions + .map(userSession => { + // keep users in the same order as userSessions + const user = users.find(({ id }) => id === userSession.userId); + if (!user) { + return null; + } + return sessionUser(user); + }) + .filter(Boolean) as CurrentUser[]; } async signOut(token: string, seq = 0) { @@ -319,12 +311,8 @@ export class AuthService implements OnApplicationBootstrap { }); } - async getUserByEmail(email: string) { - return this.user.findUserByEmail(email); - } - - async changePassword(email: string, newPassword: string): Promise { - const user = await this.getUserByEmail(email); + async changePassword(id: string, newPassword: string): Promise { + const user = await this.user.findUserById(id); if (!user) { throw new BadRequestException('Invalid email'); @@ -343,11 +331,7 @@ export class AuthService implements OnApplicationBootstrap { } async changeEmail(id: string, newEmail: string): Promise { - const user = await this.db.user.findUnique({ - where: { - id, - }, - }); + const user = await this.user.findUserById(id); if (!user) { throw new BadRequestException('Invalid email'); diff --git a/packages/backend/server/src/core/auth/token.ts b/packages/backend/server/src/core/auth/token.ts index b154b7cbd6..3027a7b90a 100644 --- a/packages/backend/server/src/core/auth/token.ts +++ b/packages/backend/server/src/core/auth/token.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { PrismaClient } from '@prisma/client'; import { CryptoHelper } from '../../fundamentals/helpers'; @@ -81,4 +82,15 @@ export class TokenService { return valid ? record : null; } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + cleanExpiredTokens() { + return this.db.verificationToken.deleteMany({ + where: { + expiresAt: { + lte: new Date(), + }, + }, + }); + } } diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index 203ab66eec..48ca191f93 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -13,12 +13,6 @@ declare global { } } -export enum ExternalAccount { - github = 'github', - google = 'google', - firebase = 'firebase', -} - export type ServerFlavor = 'allinone' | 'graphql' | 'sync'; export type AFFINE_ENV = 'dev' | 'beta' | 'production'; export type NODE_ENV = 'development' | 'test' | 'production'; diff --git a/packages/backend/server/src/fundamentals/prisma/index.ts b/packages/backend/server/src/fundamentals/prisma/index.ts index 517997fec5..91f4406575 100644 --- a/packages/backend/server/src/fundamentals/prisma/index.ts +++ b/packages/backend/server/src/fundamentals/prisma/index.ts @@ -6,7 +6,13 @@ import { PrismaService } from './service'; // only `PrismaClient` can be injected const clientProvider: Provider = { provide: PrismaClient, - useClass: PrismaService, + useFactory: () => { + if (PrismaService.INSTANCE) { + return PrismaService.INSTANCE; + } + + return new PrismaService(); + }, }; @Global() diff --git a/packages/backend/server/src/fundamentals/prisma/service.ts b/packages/backend/server/src/fundamentals/prisma/service.ts index 76fb92b392..3f326e50d4 100644 --- a/packages/backend/server/src/fundamentals/prisma/service.ts +++ b/packages/backend/server/src/fundamentals/prisma/service.ts @@ -19,6 +19,9 @@ export class PrismaService } async onModuleDestroy(): Promise { - await this.$disconnect(); + if (!AFFiNE.node.test) { + await this.$disconnect(); + PrismaService.INSTANCE = null; + } } } diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts index 8be8d69a5a..3d4ac90f65 100644 --- a/packages/backend/server/src/plugins/oauth/controller.ts +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -40,7 +40,7 @@ export class OAuthController { const provider = this.providerFactory.get(providerName); if (!provider) { - throw new BadRequestException('Invalid provider'); + throw new BadRequestException('Invalid OAuth provider'); } const state = await this.oauth.saveOAuthState({ diff --git a/packages/backend/server/src/plugins/oauth/register.ts b/packages/backend/server/src/plugins/oauth/register.ts index d6c53c57d2..3eeccae7c6 100644 --- a/packages/backend/server/src/plugins/oauth/register.ts +++ b/packages/backend/server/src/plugins/oauth/register.ts @@ -16,7 +16,7 @@ export function registerOAuthProvider( @Injectable() export class OAuthProviderFactory { get providers() { - return PROVIDERS.keys(); + return Array.from(PROVIDERS.keys()); } get(name: OAuthProviderName): OAuthProvider | undefined { diff --git a/packages/backend/server/tests/auth/controller.spec.ts b/packages/backend/server/tests/auth/controller.spec.ts new file mode 100644 index 0000000000..b8a967957c --- /dev/null +++ b/packages/backend/server/tests/auth/controller.spec.ts @@ -0,0 +1,164 @@ +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; +import request from 'supertest'; + +import { AuthModule, CurrentUser } from '../../src/core/auth'; +import { AuthService } from '../../src/core/auth/service'; +import { FeatureModule } from '../../src/core/features'; +import { UserModule, UserService } from '../../src/core/user'; +import { MailService } from '../../src/fundamentals'; +import { createTestingApp, getSession, sessionCookie } from '../utils'; + +const test = ava as TestFn<{ + auth: AuthService; + user: UserService; + u1: CurrentUser; + db: PrismaClient; + mailer: Sinon.SinonStubbedInstance; + app: INestApplication; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [FeatureModule, UserModule, AuthModule], + tapModule: m => { + m.overrideProvider(MailService).useValue( + Sinon.createStubInstance(MailService) + ); + }, + }); + + t.context.auth = app.get(AuthService); + t.context.user = app.get(UserService); + t.context.db = app.get(PrismaClient); + t.context.mailer = app.get(MailService); + t.context.app = app; + + t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1'); +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +test('should be able to sign in with credential', async t => { + const { app, u1 } = t.context; + + const res = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email, password: '1' }) + .expect(200); + + const session = await getSession(app, res); + t.is(session.user!.id, u1.id); +}); + +test('should be able to sign in with email', async t => { + const { app, u1, mailer } = t.context; + + // @ts-expect-error mock + mailer.sendSignInMail.resolves({ rejected: [] }); + + const res = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email }) + .expect(200); + + t.is(res.body.email, u1.email); + t.true(mailer.sendSignInMail.calledOnce); + + let [signInLink] = mailer.sendSignInMail.firstCall.args; + const url = new URL(signInLink); + signInLink = url.pathname + url.search; + + const signInRes = await request(app.getHttpServer()) + .get(signInLink) + .expect(302); + + const session = await getSession(app, signInRes); + t.is(session.user!.id, u1.id); +}); + +test('should be able to sign up with email', async t => { + const { app, mailer } = t.context; + + // @ts-expect-error mock + mailer.sendSignUpMail.resolves({ rejected: [] }); + + const res = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: 'u2@affine.pro' }) + .expect(200); + + t.is(res.body.email, 'u2@affine.pro'); + t.true(mailer.sendSignUpMail.calledOnce); + + let [signUpLink] = mailer.sendSignUpMail.firstCall.args; + const url = new URL(signUpLink); + signUpLink = url.pathname + url.search; + + const signInRes = await request(app.getHttpServer()) + .get(signUpLink) + .expect(302); + + const session = await getSession(app, signInRes); + t.is(session.user!.email, 'u2@affine.pro'); +}); + +test('should not be able to sign in if email is invalid', async t => { + const { app } = t.context; + + const res = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: '' }) + .expect(400); + + t.is(res.body.message, 'Invalid email address'); +}); + +test('should not be able to sign in if forbidden', async t => { + const { app, auth, u1, mailer } = t.context; + + const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false); + + await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email }) + .expect(HttpStatus.PAYMENT_REQUIRED); + + t.true(mailer.sendSignInMail.notCalled); + + canSignInStub.restore(); +}); + +test('should be able to sign out', async t => { + const { app, u1 } = t.context; + + const signInRes = await request(app.getHttpServer()) + .post('/api/auth/sign-in') + .send({ email: u1.email, password: '1' }) + .expect(200); + + const cookie = sessionCookie(signInRes.headers); + + await request(app.getHttpServer()) + .get('/api/auth/sign-out') + .set('cookie', cookie) + .expect(200); + + const session = await getSession(app, signInRes); + + t.falsy(session.user); +}); + +test('should not be able to sign out if not signed in', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/api/auth/sign-out') + .expect(HttpStatus.UNAUTHORIZED); + + t.assert(true); +}); diff --git a/packages/backend/server/tests/auth/guard.spec.ts b/packages/backend/server/tests/auth/guard.spec.ts new file mode 100644 index 0000000000..78dccc8905 --- /dev/null +++ b/packages/backend/server/tests/auth/guard.spec.ts @@ -0,0 +1,131 @@ +import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; +import request from 'supertest'; + +import { + AuthGuard, + AuthModule, + CurrentUser, + Public, +} from '../../src/core/auth'; +import { AuthService } from '../../src/core/auth/service'; +import { createTestingApp } from '../utils'; + +@Controller('/') +class TestController { + @Public() + @Get('/public') + home(@CurrentUser() user?: CurrentUser) { + return { user }; + } + + @Get('/private') + private(@CurrentUser() user: CurrentUser) { + return { user }; + } +} + +const test = ava as TestFn<{ + app: INestApplication; + auth: Sinon.SinonStubbedInstance; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [AuthModule], + providers: [ + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + ], + controllers: [TestController], + tapModule: m => { + m.overrideProvider(AuthService).useValue( + Sinon.createStubInstance(AuthService) + ); + }, + }); + + t.context.auth = app.get(AuthService); + t.context.app = app; +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +test('should be able to visit public api if not signed in', async t => { + const { app } = t.context; + + const res = await request(app.getHttpServer()).get('/public').expect(200); + + t.is(res.body.user, undefined); +}); + +test('should be able to visit public api if signed in', async t => { + const { app, auth } = t.context; + + // @ts-expect-error mock + auth.getUser.resolves({ id: '1' }); + + const res = await request(app.getHttpServer()) + .get('/public') + .set('Cookie', 'sid=1') + .expect(HttpStatus.OK); + + t.is(res.body.user.id, '1'); +}); + +test('should not be able to visit private api if not signed in', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/private') + .expect(HttpStatus.UNAUTHORIZED) + .expect({ + statusCode: 401, + message: 'You are not signed in.', + error: 'Unauthorized', + }); + + t.assert(true); +}); + +test('should be able to visit private api if signed in', async t => { + const { app, auth } = t.context; + + // @ts-expect-error mock + auth.getUser.resolves({ id: '1' }); + + const res = await request(app.getHttpServer()) + .get('/private') + .set('Cookie', 'sid=1') + .expect(HttpStatus.OK); + + t.is(res.body.user.id, '1'); +}); + +test('should be able to parse session cookie', async t => { + const { app, auth } = t.context; + + await request(app.getHttpServer()) + .get('/public') + .set('cookie', 'sid=1') + .expect(200); + + t.deepEqual(auth.getUser.firstCall.args, ['1', 0]); +}); + +test('should be able to parse bearer token', async t => { + const { app, auth } = t.context; + + await request(app.getHttpServer()) + .get('/public') + .auth('1', { type: 'bearer' }) + .expect(200); + + t.deepEqual(auth.getUser.firstCall.args, ['1', 0]); +}); diff --git a/packages/backend/server/tests/auth/service.spec.ts b/packages/backend/server/tests/auth/service.spec.ts new file mode 100644 index 0000000000..55fbc24dd1 --- /dev/null +++ b/packages/backend/server/tests/auth/service.spec.ts @@ -0,0 +1,219 @@ +import { TestingModule } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; + +import { CurrentUser } from '../../src/core/auth'; +import { AuthService, parseAuthUserSeqNum } from '../../src/core/auth/service'; +import { FeatureModule } from '../../src/core/features'; +import { UserModule, UserService } from '../../src/core/user'; +import { createTestingModule } from '../utils'; + +const test = ava as TestFn<{ + auth: AuthService; + user: UserService; + u1: CurrentUser; + db: PrismaClient; + m: TestingModule; +}>; + +test.beforeEach(async t => { + const m = await createTestingModule({ + imports: [FeatureModule, UserModule], + providers: [AuthService], + }); + + t.context.auth = m.get(AuthService); + t.context.user = m.get(UserService); + t.context.db = m.get(PrismaClient); + t.context.m = m; + + t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1'); +}); + +test.afterEach.always(async t => { + await t.context.m.close(); +}); + +test('should be able to parse auth user seq num', t => { + t.deepEqual( + [ + '1', + '2', + 3, + -3, + '-4', + '1.1', + 'str', + '1111111111111111111111111111111111111111111', + ].map(parseAuthUserSeqNum), + [1, 2, 3, 0, 0, 0, 0, 0] + ); +}); + +test('should be able to sign up', async t => { + const { auth } = t.context; + const u2 = await auth.signUp('u2', 'u2@affine.pro', '1'); + + t.is(u2.email, 'u2@affine.pro'); + + const signedU2 = await auth.signIn(u2.email, '1'); + + t.is(u2.email, signedU2.email); +}); + +test('should throw if email duplicated', async t => { + const { auth } = t.context; + + await t.throwsAsync(() => auth.signUp('u1', 'u1@affine.pro', '1'), { + message: 'Email was taken', + }); +}); + +test('should be able to sign in', async t => { + const { auth } = t.context; + + const signedInUser = await auth.signIn('u1@affine.pro', '1'); + + t.is(signedInUser.email, 'u1@affine.pro'); +}); + +test('should throw if user not found', async t => { + const { auth } = t.context; + + await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), { + message: 'Invalid sign in credentials', + }); +}); + +test('should throw if password not set', async t => { + const { user, auth } = t.context; + + await user.createUser({ + email: 'u2@affine.pro', + name: 'u2', + }); + + await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), { + message: 'User Password is not set. Should login through email link.', + }); +}); + +test('should throw if password not match', async t => { + const { auth } = t.context; + + await t.throwsAsync(() => auth.signIn('u1@affine.pro', '2'), { + message: 'Invalid sign in credentials', + }); +}); + +test('should be able to change password', async t => { + const { auth, u1 } = t.context; + + let signedInU1 = await auth.signIn('u1@affine.pro', '1'); + t.is(signedInU1.email, u1.email); + + await auth.changePassword(u1.id, '2'); + + await t.throwsAsync( + () => auth.signIn('u1@affine.pro', '1' /* old password */), + { + message: 'Invalid sign in credentials', + } + ); + + signedInU1 = await auth.signIn('u1@affine.pro', '2'); + t.is(signedInU1.email, u1.email); +}); + +test('should be able to change email', async t => { + const { auth, u1 } = t.context; + + let signedInU1 = await auth.signIn('u1@affine.pro', '1'); + t.is(signedInU1.email, u1.email); + + await auth.changeEmail(u1.id, 'u2@affine.pro'); + + await t.throwsAsync(() => auth.signIn('u1@affine.pro' /* old email */, '1'), { + message: 'Invalid sign in credentials', + }); + + signedInU1 = await auth.signIn('u2@affine.pro', '1'); + t.is(signedInU1.email, 'u2@affine.pro'); +}); + +// Tests for Session +test('should be able to create user session', async t => { + const { auth, u1 } = t.context; + + const session = await auth.createUserSession(u1); + + t.is(session.userId, u1.id); +}); + +test('should be able to get user from session', async t => { + const { auth, u1 } = t.context; + + const session = await auth.createUserSession(u1); + + const user = await auth.getUser(session.sessionId); + + t.not(user, null); + t.is(user!.id, u1.id); +}); + +test('should be able to sign out session', async t => { + const { auth, u1 } = t.context; + + const session = await auth.createUserSession(u1); + + const signedOutSession = await auth.signOut(session.sessionId); + + t.is(signedOutSession, null); +}); + +// Tests for Multi-Accounts Session +test('should be able to sign in different user in a same session', async t => { + const { auth, u1 } = t.context; + + const u2 = await auth.signUp('u2', 'u2@affine.pro', '1'); + + const session = await auth.createUserSession(u1); + await auth.createUserSession(u2, session.sessionId); + + const [signedU1, signedU2] = await auth.getUserList(session.sessionId); + + t.not(signedU1, null); + t.not(signedU2, null); + t.is(signedU1!.id, u1.id); + t.is(signedU2!.id, u2.id); +}); + +test('should be able to signout multi accounts session', async t => { + const { auth, u1 } = t.context; + + const u2 = await auth.signUp('u2', 'u2@affine.pro', '1'); + + const session = await auth.createUserSession(u1); + await auth.createUserSession(u2, session.sessionId); + + // sign out user at seq(0) + let signedOutSession = await auth.signOut(session.sessionId); + + t.not(signedOutSession, null); + + const signedU2 = await auth.getUser(session.sessionId, 0); + const noUser = await auth.getUser(session.sessionId, 1); + + t.is(noUser, null); + t.not(signedU2, null); + + t.is(signedU2!.id, u2.id); + + // sign out user at seq(0) + signedOutSession = await auth.signOut(session.sessionId); + + t.is(signedOutSession, null); + + const noUser2 = await auth.getUser(session.sessionId, 0); + t.is(noUser2, null); +}); diff --git a/packages/backend/server/tests/auth/token.spec.ts b/packages/backend/server/tests/auth/token.spec.ts new file mode 100644 index 0000000000..b7818e89d9 --- /dev/null +++ b/packages/backend/server/tests/auth/token.spec.ts @@ -0,0 +1,93 @@ +import { TestingModule } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; + +import { TokenService, TokenType } from '../../src/core/auth'; +import { createTestingModule } from '../utils'; + +const test = ava as TestFn<{ + ts: TokenService; + m: TestingModule; +}>; + +test.beforeEach(async t => { + const m = await createTestingModule({ + providers: [TokenService], + }); + + t.context.ts = m.get(TokenService); + t.context.m = m; +}); + +test.afterEach.always(async t => { + await t.context.m.close(); +}); + +test('should be able to create token', async t => { + const { ts } = t.context; + const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); + + t.truthy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'user@affine.pro', + }) + ); +}); + +test('should fail the verification if the token is invalid', async t => { + const { ts } = t.context; + + const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); + + // wrong type + t.falsy( + await ts.verifyToken(TokenType.ChangeEmail, token, { + credential: 'user@affine.pro', + }) + ); + + // no credential + t.falsy(await ts.verifyToken(TokenType.SignIn, token)); + + // wrong credential + t.falsy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'wrong@affine.pro', + }) + ); +}); + +test('should fail if the token expired', async t => { + const { ts } = t.context; + const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); + + await t.context.m.get(PrismaClient).verificationToken.updateMany({ + data: { + expiresAt: new Date(Date.now() - 1000), + }, + }); + + t.falsy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'user@affine.pro', + }) + ); +}); + +test('should be able to verify only once', async t => { + const { ts } = t.context; + const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro'); + + t.truthy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'user@affine.pro', + }) + ); + + // will be invalid after the first time of verification + t.falsy( + await ts.verifyToken(TokenType.SignIn, token, { + credential: 'user@affine.pro', + }) + ); +}); diff --git a/packages/backend/server/tests/oauth/controller.spec.ts b/packages/backend/server/tests/oauth/controller.spec.ts new file mode 100644 index 0000000000..542de3a0c1 --- /dev/null +++ b/packages/backend/server/tests/oauth/controller.spec.ts @@ -0,0 +1,345 @@ +import '../../src/plugins/config'; + +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; +import request from 'supertest'; + +import { AppModule } from '../../src/app.module'; +import { CurrentUser } from '../../src/core/auth'; +import { AuthService } from '../../src/core/auth/service'; +import { UserService } from '../../src/core/user'; +import { Config, ConfigModule } from '../../src/fundamentals/config'; +import { GoogleOAuthProvider } from '../../src/plugins/oauth/providers/google'; +import { OAuthService } from '../../src/plugins/oauth/service'; +import { OAuthProviderName } from '../../src/plugins/oauth/types'; +import { createTestingApp, getSession } from '../utils'; + +const test = ava as TestFn<{ + auth: AuthService; + oauth: OAuthService; + user: UserService; + u1: CurrentUser; + db: PrismaClient; + app: INestApplication; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [ + ConfigModule.forRoot({ + plugins: { + oauth: { + providers: { + google: { + clientId: 'google-client-id', + clientSecret: 'google-client-secret', + }, + }, + }, + }, + }), + AppModule, + ], + }); + + t.context.auth = app.get(AuthService); + t.context.oauth = app.get(OAuthService); + t.context.user = app.get(UserService); + t.context.db = app.get(PrismaClient); + t.context.app = app; + + t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1'); +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +test("should be able to redirect to oauth provider's login page", async t => { + const { app } = t.context; + + const res = await request(app.getHttpServer()) + .get('/oauth/login?provider=Google') + .expect(HttpStatus.FOUND); + + const redirect = new URL(res.header.location); + t.is(redirect.origin, 'https://accounts.google.com'); + + t.is(redirect.pathname, '/o/oauth2/v2/auth'); + t.is(redirect.searchParams.get('client_id'), 'google-client-id'); + t.is( + redirect.searchParams.get('redirect_uri'), + app.get(Config).baseUrl + '/oauth/callback' + ); + t.is(redirect.searchParams.get('response_type'), 'code'); + t.is(redirect.searchParams.get('prompt'), 'select_account'); + t.truthy(redirect.searchParams.get('state')); +}); + +test('should throw if provider is invalid', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/oauth/login?provider=Invalid') + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Invalid OAuth provider', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should be able to save oauth state', async t => { + const { oauth } = t.context; + + const id = await oauth.saveOAuthState({ + redirectUri: 'https://example.com', + provider: OAuthProviderName.Google, + }); + + const state = await oauth.getOAuthState(id); + + t.truthy(state); + t.is(state!.provider, OAuthProviderName.Google); + t.is(state!.redirectUri, 'https://example.com'); +}); + +test('should be able to get registered oauth providers', async t => { + const { oauth } = t.context; + + const providers = oauth.availableOAuthProviders(); + + t.deepEqual(providers, [OAuthProviderName.Google]); +}); + +test('should throw if code is missing in callback uri', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/oauth/callback') + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Missing query parameter `code`', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should throw if state is missing in callback uri', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/oauth/callback?code=1') + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Invalid callback state parameter', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should throw if state is expired', async t => { + const { app } = t.context; + + await request(app.getHttpServer()) + .get('/oauth/callback?code=1&state=1') + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'OAuth state expired, please try again.', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should throw if provider is missing in state', async t => { + const { app, oauth } = t.context; + + // @ts-expect-error mock + Sinon.stub(oauth, 'getOAuthState').resolves({}); + + await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Missing callback state parameter `provider`', + error: 'Bad Request', + }); + + t.assert(true); +}); + +test('should throw if provider is invalid in callback uri', async t => { + const { app, oauth } = t.context; + + // @ts-expect-error mock + Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' }); + + await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.BAD_REQUEST) + .expect({ + statusCode: 400, + message: 'Invalid provider', + error: 'Bad Request', + }); + + t.assert(true); +}); + +function mockOAuthProvider(app: INestApplication, email: string) { + const provider = app.get(GoogleOAuthProvider); + const oauth = app.get(OAuthService); + + Sinon.stub(oauth, 'getOAuthState').resolves({ + provider: OAuthProviderName.Google, + redirectUri: '/', + }); + + // @ts-expect-error mock + Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' }); + Sinon.stub(provider, 'getUser').resolves({ + id: '1', + email, + avatarUrl: 'avatar', + }); +} + +test('should be able to sign up with oauth', async t => { + const { app, db } = t.context; + + mockOAuthProvider(app, 'u2@affine.pro'); + + const res = await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.FOUND); + + const session = await getSession(app, res); + + t.truthy(session.user); + t.is(session.user!.email, 'u2@affine.pro'); + + const user = await db.user.findFirst({ + select: { + email: true, + connectedAccounts: true, + }, + where: { + email: 'u2@affine.pro', + }, + }); + + t.truthy(user); + t.is(user!.email, 'u2@affine.pro'); + t.is(user!.connectedAccounts[0].providerAccountId, '1'); +}); + +test('should throw if account register in another way', async t => { + const { app, u1 } = t.context; + + mockOAuthProvider(app, u1.email); + + const res = await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.FOUND); + + const link = new URL(res.headers.location); + + t.is(link.pathname, '/signIn'); + t.is( + link.searchParams.get('error'), + 'The account with provided email is not register in the same way.' + ); +}); + +test('should be able to fullfil user with oauth sign in', async t => { + const { app, user, db } = t.context; + + const u3 = await user.createUser({ + name: 'u3', + email: 'u3@affine.pro', + registered: false, + }); + + mockOAuthProvider(app, u3.email); + + const res = await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .expect(HttpStatus.FOUND); + + const session = await getSession(app, res); + + t.truthy(session.user); + t.is(session.user!.email, u3.email); + + const account = await db.connectedAccount.findFirst({ + where: { + userId: u3.id, + }, + }); + + t.truthy(account); +}); + +test('should throw if oauth account already connected', async t => { + const { app, db, u1, auth } = t.context; + + await db.connectedAccount.create({ + data: { + userId: u1.id, + provider: OAuthProviderName.Google, + providerAccountId: '1', + }, + }); + + // @ts-expect-error mock + Sinon.stub(auth, 'getUser').resolves({ id: 'u2-id' }); + + mockOAuthProvider(app, 'u2@affine.pro'); + + const res = await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .set('cookie', 'sid=1') + .expect(HttpStatus.FOUND); + + const link = new URL(res.headers.location); + + t.is(link.pathname, '/signIn'); + t.is( + link.searchParams.get('error'), + 'The third-party account has already been connected to another user.' + ); +}); + +test('should be able to connect oauth account', async t => { + const { app, u1, auth, db } = t.context; + + // @ts-expect-error mock + Sinon.stub(auth, 'getUser').resolves({ id: u1.id }); + + mockOAuthProvider(app, u1.email); + + await request(app.getHttpServer()) + .get(`/oauth/callback?code=1&state=1`) + .set('cookie', 'sid=1') + .expect(HttpStatus.FOUND); + + const account = await db.connectedAccount.findFirst({ + where: { + userId: u1.id, + }, + }); + + t.truthy(account); + t.is(account!.userId, u1.id); +}); diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts index f57dce3e39..8f238f5d03 100644 --- a/packages/backend/server/tests/utils/user.ts +++ b/packages/backend/server/tests/utils/user.ts @@ -1,11 +1,36 @@ import type { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import request from 'supertest'; +import request, { type Response } from 'supertest'; -import type { ClientTokenType } from '../../src/core/auth'; +import type { ClientTokenType, CurrentUser } from '../../src/core/auth'; import type { UserType } from '../../src/core/user'; import { gql } from './common'; +export function sessionCookie(headers: any) { + const cookie = headers['set-cookie']?.find((c: string) => + c.startsWith('sid=') + ); + + if (!cookie) { + return null; + } + + return cookie.split(';')[0]; +} + +export async function getSession( + app: INestApplication, + signInRes: Response +): Promise<{ user?: CurrentUser }> { + const cookie = sessionCookie(signInRes.headers); + const res = await request(app.getHttpServer()) + .get('/api/auth/session') + .set('cookie', cookie) + .expect(200); + + return res.body; +} + export async function signUp( app: INestApplication, name: string, diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index 8d3c32bf55..88351d2df9 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -113,6 +113,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { cors: true, bodyParser: true, rawBody: true, + logger: ['warn'], }); app.use( diff --git a/packages/backend/server/tests/workspace-invite.e2e.ts b/packages/backend/server/tests/workspace-invite.e2e.ts index 0a5de885fa..c92f2c66c0 100644 --- a/packages/backend/server/tests/workspace-invite.e2e.ts +++ b/packages/backend/server/tests/workspace-invite.e2e.ts @@ -9,6 +9,7 @@ import ava from 'ava'; import { AppModule } from '../src/app.module'; import { AuthService } from '../src/core/auth/service'; +import { UserService } from '../src/core/user'; import { MailService } from '../src/fundamentals/mailer'; import { acceptInviteById, @@ -26,6 +27,7 @@ const test = ava as TestFn<{ client: PrismaClient; auth: AuthService; mail: MailService; + user: UserService; }>; test.beforeEach(async t => { @@ -36,6 +38,7 @@ test.beforeEach(async t => { t.context.client = app.get(PrismaClient); t.context.auth = app.get(AuthService); t.context.mail = app.get(MailService); + t.context.user = app.get(UserService); }); test.afterEach.always(async t => { @@ -96,16 +99,16 @@ test('should revoke a user', async t => { }); test('should create user if not exist', async t => { - const { app, auth } = t.context; + const { app, user } = t.context; const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); const workspace = await createWorkspace(app, u1.token.token); await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro', 'Admin'); - const user = await auth.getUserByEmail('u2@affine.pro'); - t.not(user, undefined, 'failed to create user'); - t.is(user?.name, 'u2', 'failed to create user'); + const u2 = await user.findUserByEmail('u2@affine.pro'); + t.not(u2, undefined, 'failed to create user'); + t.is(u2?.name, 'u2', 'failed to create user'); }); test('should invite a user by link', async t => { diff --git a/packages/frontend/templates/templates.gen.ts b/packages/frontend/templates/templates.gen.ts index d95d5c0eed..6858f0b2b3 100644 --- a/packages/frontend/templates/templates.gen.ts +++ b/packages/frontend/templates/templates.gen.ts @@ -1,11 +1,11 @@ /* eslint-disable simple-import-sort/imports */ // Auto generated, do not edit manually -import json_0 from './onboarding/W-d9_llZ6rE-qoTiHKTk4.snapshot.json'; -import json_1 from './onboarding/info.json'; -import json_2 from './onboarding/blob.json'; +import json_0 from './onboarding/info.json'; +import json_1 from './onboarding/blob.json'; +import json_2 from './onboarding/W-d9_llZ6rE-qoTiHKTk4.snapshot.json'; export const onboarding = { - 'W-d9_llZ6rE-qoTiHKTk4.snapshot.json': json_0, - 'info.json': json_1, - 'blob.json': json_2 + 'info.json': json_0, + 'blob.json': json_1, + 'W-d9_llZ6rE-qoTiHKTk4.snapshot.json': json_2 } \ No newline at end of file From 2662ba763c4c33419d88946db1f9121bb56cd607 Mon Sep 17 00:00:00 2001 From: renovate <29139614+renovate@users.noreply.github.com> Date: Tue, 26 Mar 2024 03:17:49 +0000 Subject: [PATCH 05/12] chore: bump up express version to v4.19.2 [SECURITY] (#6308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [express](http://expressjs.com/) ([source](https://togithub.com/expressjs/express)) | [`4.18.2` -> `4.19.2`](https://renovatebot.com/diffs/npm/express/4.18.2/4.19.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/express/4.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/express/4.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/express/4.18.2/4.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/express/4.18.2/4.19.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | ### GitHub Vulnerability Alerts #### [CVE-2024-29041](https://togithub.com/expressjs/express/security/advisories/GHSA-rv95-896h-c2vc) ### Impact Versions of Express.js prior to 4.19.2 and pre-release alpha and beta versions before 5.0.0-beta.3 are affected by an open redirect vulnerability using malformed URLs. When a user of Express performs a redirect using a user-provided URL Express performs an encode [using `encodeurl`](https://togithub.com/pillarjs/encodeurl) on the contents before passing it to the `location` header. This can cause malformed URLs to be evaluated in unexpected ways by common redirect allow list implementations in Express applications, leading to an Open Redirect via bypass of a properly implemented allow list. The main method impacted is `res.location()` but this is also called from within `res.redirect()`. ### Patches https://github.com/expressjs/express/commit/0867302ddbde0e9463d0564fea5861feb708c2dd https://github.com/expressjs/express/commit/0b746953c4bd8e377123527db11f9cd866e39f94 An initial fix went out with `express@4.19.0`, we then patched a feature regression in `4.19.1` and added improved handling for the bypass in `4.19.2`. ### Workarounds The fix for this involves pre-parsing the url string with either `require('node:url').parse` or `new URL`. These are steps you can take on your own before passing the user input string to `res.location` or `res.redirect`. ### References [https://github.com/expressjs/express/pull/5539](https://togithub.com/expressjs/express/pull/5539) [https://github.com/koajs/koa/issues/1800](https://togithub.com/koajs/koa/issues/1800) https://expressjs.com/en/4x/api.html#res.location --- ### Release Notes
expressjs/express (express) ### [`v4.19.2`](https://togithub.com/expressjs/express/blob/HEAD/History.md#4192--2024-03-25) [Compare Source](https://togithub.com/expressjs/express/compare/4.19.1...4.19.2) \========== - Improved fix for open redirect allow list bypass ### [`v4.19.1`](https://togithub.com/expressjs/express/blob/HEAD/History.md#4191--2024-03-20) [Compare Source](https://togithub.com/expressjs/express/compare/4.19.0...4.19.1) \========== - Allow passing non-strings to res.location with new encoding handling checks ### [`v4.19.0`](https://togithub.com/expressjs/express/compare/4.18.3...83e77aff6a3859d58206f3ff9501277023c03f87) [Compare Source](https://togithub.com/expressjs/express/compare/4.18.3...4.19.0) ### [`v4.18.3`](https://togithub.com/expressjs/express/blob/HEAD/History.md#4183--2024-02-26) [Compare Source](https://togithub.com/expressjs/express/compare/4.18.2...4.18.3) \========== - Fix routing requests without method - deps: body-parser@1.20.2 - Fix strict json error message on Node.js 19+ - deps: content-type@~1.0.5 - deps: raw-body@2.5.2
--- ### Configuration 📅 **Schedule**: Branch creation - "" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/toeverything/AFFiNE). --- yarn.lock | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 1d7c3ccc1f..7d5105da09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21139,7 +21139,7 @@ __metadata: languageName: node linkType: hard -"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.2": +"express@npm:4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: @@ -21178,6 +21178,45 @@ __metadata: languageName: node linkType: hard +"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.2": + version: 4.19.2 + resolution: "express@npm:4.19.2" + dependencies: + accepts: "npm:~1.3.8" + array-flatten: "npm:1.1.1" + body-parser: "npm:1.20.2" + content-disposition: "npm:0.5.4" + content-type: "npm:~1.0.4" + cookie: "npm:0.6.0" + cookie-signature: "npm:1.0.6" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + encodeurl: "npm:~1.0.2" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + finalhandler: "npm:1.2.0" + fresh: "npm:0.5.2" + http-errors: "npm:2.0.0" + merge-descriptors: "npm:1.0.1" + methods: "npm:~1.1.2" + on-finished: "npm:2.4.1" + parseurl: "npm:~1.3.3" + path-to-regexp: "npm:0.1.7" + proxy-addr: "npm:~2.0.7" + qs: "npm:6.11.0" + range-parser: "npm:~1.2.1" + safe-buffer: "npm:5.2.1" + send: "npm:0.18.0" + serve-static: "npm:1.15.0" + setprototypeof: "npm:1.2.0" + statuses: "npm:2.0.1" + type-is: "npm:~1.6.18" + utils-merge: "npm:1.0.1" + vary: "npm:~1.1.2" + checksum: 10/3fcd792536f802c059789ef48db3851b87e78fba103423e524144d79af37da7952a2b8d4e1a007f423329c7377d686d9476ac42e7d9ea413b80345d495e30a3a + languageName: node + linkType: hard + "extend@npm:^3.0.0, extend@npm:^3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" From e9bc24bf37a82e8318b8889b9c8bbe6d664daed5 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Tue, 26 Mar 2024 03:29:37 +0000 Subject: [PATCH 06/12] fix(electron): possible issue on openning two main windows (#6307) fix https://github.com/toeverything/AFFiNE/issues/6303 fetching `getWindowAdditionalArguments` requires forking a new process & handshake, which could be time consuming --- packages/frontend/electron/src/main/main-window.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/frontend/electron/src/main/main-window.ts b/packages/frontend/electron/src/main/main-window.ts index d8ce4e45aa..e27f1bd89c 100644 --- a/packages/frontend/electron/src/main/main-window.ts +++ b/packages/frontend/electron/src/main/main-window.ts @@ -183,8 +183,10 @@ let hiddenMacWindow: BrowserWindow | undefined; */ export async function initAndShowMainWindow() { if (!browserWindow || (await browserWindow.then(w => w.isDestroyed()))) { - const additionalArguments = await getWindowAdditionalArguments(); - browserWindow = createWindow(additionalArguments); + browserWindow = (async () => { + const additionalArguments = await getWindowAdditionalArguments(); + return createWindow(additionalArguments); + })(); } const mainWindow = await browserWindow; From ffbfdb65a21f80a9afe449a69285c8f19b6c94f8 Mon Sep 17 00:00:00 2001 From: Brooooooklyn Date: Tue, 26 Mar 2024 03:41:40 +0000 Subject: [PATCH 07/12] fix(core): add env info to tracks (#6313) --- packages/frontend/core/src/router.tsx | 4 ++++ tools/cli/src/webpack/config.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx index 053963a604..17642447ad 100644 --- a/packages/frontend/core/src/router.tsx +++ b/packages/frontend/core/src/router.tsx @@ -14,6 +14,10 @@ function RootRouter() { useEffect(() => { mixpanel.track_pageview({ page: location.pathname, + appVersion: runtimeConfig.appVersion, + environment: runtimeConfig.appBuildType, + editorVersion: runtimeConfig.editorVersion, + isSelfHosted: Boolean(runtimeConfig.isSelfHosted), }); }, [location]); return ; diff --git a/tools/cli/src/webpack/config.ts b/tools/cli/src/webpack/config.ts index 159a7778ad..4f155a3522 100644 --- a/tools/cli/src/webpack/config.ts +++ b/tools/cli/src/webpack/config.ts @@ -349,6 +349,7 @@ export const createConfiguration: ( ), 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), 'process.env.BUILD_TYPE': JSON.stringify(process.env.BUILD_TYPE), + 'process.env.MIXPANEL_TOKEN': `"${process.env.MIXPANEL_TOKEN}"`, runtimeConfig: JSON.stringify(runtimeConfig), }), new CopyPlugin({ From 095f8c2359053bf2dbf3eea39c58ba782298211f Mon Sep 17 00:00:00 2001 From: pengx17 Date: Tue, 26 Mar 2024 03:57:58 +0000 Subject: [PATCH 08/12] fix: button should have its font-family inherited (#6311) --- packages/frontend/component/src/theme/global.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/component/src/theme/global.css b/packages/frontend/component/src/theme/global.css index a111fb7d33..5bbbb061dc 100644 --- a/packages/frontend/component/src/theme/global.css +++ b/packages/frontend/component/src/theme/global.css @@ -171,7 +171,7 @@ legend { outline: 0; border: 0; font-size: var(--affine-font-base); - font-family: var(--affine-font-family); + font-family: inherit; font-feature-settings: 'calt' 0; } From 669ca325a1d9a9081a29f0a0c50ac2b3306b74e4 Mon Sep 17 00:00:00 2001 From: JimmFly <447268514@qq.com> Date: Tue, 26 Mar 2024 04:57:41 +0000 Subject: [PATCH 09/12] fix(core): tag color should use palette-line-color (#6315) --- packages/frontend/core/src/modules/tag/entities/tag.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/frontend/core/src/modules/tag/entities/tag.ts b/packages/frontend/core/src/modules/tag/entities/tag.ts index 0701495631..bc1f8f7b3c 100644 --- a/packages/frontend/core/src/modules/tag/entities/tag.ts +++ b/packages/frontend/core/src/modules/tag/entities/tag.ts @@ -3,6 +3,7 @@ import type { PageRecordList } from '@toeverything/infra'; import { LiveData } from '@toeverything/infra'; import type { WorkspaceLegacyProperties } from '../../workspace'; +import { tagColorMap } from './utils'; export class Tag { constructor( @@ -17,7 +18,7 @@ export class Tag { value$ = this.tagOption$.map(tag => tag?.value || ''); - color$ = this.tagOption$.map(tag => tag?.color || ''); + color$ = this.tagOption$.map(tag => tagColorMap(tag?.color) || ''); createDate$ = this.tagOption$.map(tag => tag?.createDate || Date.now()); From d8a3cd5ce227a5519b7a46f7f925e1fca8051900 Mon Sep 17 00:00:00 2001 From: Brooooooklyn Date: Tue, 26 Mar 2024 05:58:21 +0000 Subject: [PATCH 10/12] chore: bump oxlint and rules (#6314) --- package.json | 4 +- .../component/src/ui/checkbox/checkbox.tsx | 2 +- yarn.lock | 86 ++++++++++++------- 3 files changed, 56 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 9398b5ef70..bf71ee8cf3 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "lint:eslint:fix": "yarn lint:eslint --fix", "lint:prettier": "prettier --ignore-unknown --cache --check .", "lint:prettier:fix": "prettier --ignore-unknown --cache --write .", - "lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export", + "lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export -A no-unresolved -A no-default-export -A no-duplicates -A no-side-effects-in-initialization -A no-named-as-default -A getter-return", "lint": "yarn lint:eslint && yarn lint:prettier", "lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix", "test": "vitest --run", @@ -97,7 +97,7 @@ "nanoid": "^5.0.6", "nx": "^18.0.4", "nyc": "^15.1.0", - "oxlint": "0.0.22", + "oxlint": "0.2.14", "prettier": "^3.2.5", "semver": "^7.6.0", "serve": "^14.2.1", diff --git a/packages/frontend/component/src/ui/checkbox/checkbox.tsx b/packages/frontend/component/src/ui/checkbox/checkbox.tsx index d8e5dc1843..0eb45202ba 100644 --- a/packages/frontend/component/src/ui/checkbox/checkbox.tsx +++ b/packages/frontend/component/src/ui/checkbox/checkbox.tsx @@ -27,7 +27,7 @@ export type CheckboxProps = Omit< export const Checkbox = ({ checked, onChange, - indeterminate: indeterminate, + indeterminate, disabled, animation, name, diff --git a/yarn.lock b/yarn.lock index 7d5105da09..9087911a3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -602,7 +602,7 @@ __metadata: nanoid: "npm:^5.0.6" nx: "npm:^18.0.4" nyc: "npm:^15.1.0" - oxlint: "npm:0.0.22" + oxlint: "npm:0.2.14" prettier: "npm:^3.2.5" semver: "npm:^7.6.0" serve: "npm:^14.2.1" @@ -9400,44 +9400,58 @@ __metadata: languageName: node linkType: hard -"@oxlint/darwin-arm64@npm:0.0.22": - version: 0.0.22 - resolution: "@oxlint/darwin-arm64@npm:0.0.22" +"@oxlint/darwin-arm64@npm:0.2.14": + version: 0.2.14 + resolution: "@oxlint/darwin-arm64@npm:0.2.14" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxlint/darwin-x64@npm:0.0.22": - version: 0.0.22 - resolution: "@oxlint/darwin-x64@npm:0.0.22" +"@oxlint/darwin-x64@npm:0.2.14": + version: 0.2.14 + resolution: "@oxlint/darwin-x64@npm:0.2.14" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxlint/linux-arm64@npm:0.0.22": - version: 0.0.22 - resolution: "@oxlint/linux-arm64@npm:0.0.22" - conditions: os=linux & cpu=arm64 +"@oxlint/linux-arm64-gnu@npm:0.2.14": + version: 0.2.14 + resolution: "@oxlint/linux-arm64-gnu@npm:0.2.14" + conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxlint/linux-x64@npm:0.0.22": - version: 0.0.22 - resolution: "@oxlint/linux-x64@npm:0.0.22" - conditions: os=linux & cpu=x64 +"@oxlint/linux-arm64-musl@npm:0.2.14": + version: 0.2.14 + resolution: "@oxlint/linux-arm64-musl@npm:0.2.14" + conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxlint/win32-arm64@npm:0.0.22": - version: 0.0.22 - resolution: "@oxlint/win32-arm64@npm:0.0.22" +"@oxlint/linux-x64-gnu@npm:0.2.14": + version: 0.2.14 + resolution: "@oxlint/linux-x64-gnu@npm:0.2.14" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-x64-musl@npm:0.2.14": + version: 0.2.14 + resolution: "@oxlint/linux-x64-musl@npm:0.2.14" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/win32-arm64@npm:0.2.14": + version: 0.2.14 + resolution: "@oxlint/win32-arm64@npm:0.2.14" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxlint/win32-x64@npm:0.0.22": - version: 0.0.22 - resolution: "@oxlint/win32-x64@npm:0.0.22" +"@oxlint/win32-x64@npm:0.2.14": + version: 0.2.14 + resolution: "@oxlint/win32-x64@npm:0.2.14" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -28953,24 +28967,30 @@ __metadata: languageName: node linkType: hard -"oxlint@npm:0.0.22": - version: 0.0.22 - resolution: "oxlint@npm:0.0.22" +"oxlint@npm:0.2.14": + version: 0.2.14 + resolution: "oxlint@npm:0.2.14" dependencies: - "@oxlint/darwin-arm64": "npm:0.0.22" - "@oxlint/darwin-x64": "npm:0.0.22" - "@oxlint/linux-arm64": "npm:0.0.22" - "@oxlint/linux-x64": "npm:0.0.22" - "@oxlint/win32-arm64": "npm:0.0.22" - "@oxlint/win32-x64": "npm:0.0.22" + "@oxlint/darwin-arm64": "npm:0.2.14" + "@oxlint/darwin-x64": "npm:0.2.14" + "@oxlint/linux-arm64-gnu": "npm:0.2.14" + "@oxlint/linux-arm64-musl": "npm:0.2.14" + "@oxlint/linux-x64-gnu": "npm:0.2.14" + "@oxlint/linux-x64-musl": "npm:0.2.14" + "@oxlint/win32-arm64": "npm:0.2.14" + "@oxlint/win32-x64": "npm:0.2.14" dependenciesMeta: "@oxlint/darwin-arm64": optional: true "@oxlint/darwin-x64": optional: true - "@oxlint/linux-arm64": + "@oxlint/linux-arm64-gnu": optional: true - "@oxlint/linux-x64": + "@oxlint/linux-arm64-musl": + optional: true + "@oxlint/linux-x64-gnu": + optional: true + "@oxlint/linux-x64-musl": optional: true "@oxlint/win32-arm64": optional: true @@ -28978,7 +28998,7 @@ __metadata: optional: true bin: oxlint: bin/oxlint - checksum: 10/9d780f3293b08c83e45996187c8098e2ef8323ed02427c7a15d5cfa542f967b0e7862f76814ef3b8c4b94badd2f21eb92130de7f2f8f0dfec91e4a3a3797c63f + checksum: 10/18c46a5adfa7477d6aa0be095fd913efd5b6701873f98d31d95f28817b7bb6590e1f1bc1b774dd4aa2166290c51482921a7ada7d86d0aaf16d9ab8d804dde8cf languageName: node linkType: hard From 0731872347e9d3a9b0b1a52c921869a5c2800cce Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Tue, 26 Mar 2024 06:10:38 +0000 Subject: [PATCH 11/12] feat(core): refactor sidebar header (#6251) - Add user avatar - Move sign-out/user settings link from workspace-modal to user avatar modal - Modify the style of workspace list items - Modify gap of navigation buttons - Animate Syncing/Offline/... ![CleanShot 2024-03-22 at 10.22.38.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/7305f561-a85b-4ec6-89c2-27e2f1b63c85.gif) --- .../components/card/workspace-card/index.tsx | 102 ++++--------- .../card/workspace-card/styles.css.ts | 86 +++++++++++ .../components/card/workspace-card/styles.ts | 136 ------------------ .../component/src/ui/avatar/style.css.ts | 2 +- .../core/public/imgs/unknown-user.svg | 17 +++ .../components/app-sidebar/fallback.css.ts | 3 +- .../user-with-workspace-list/index.tsx | 2 +- .../user-account/index.tsx | 80 +---------- .../workspace-list/index.css.ts | 8 +- .../workspace-list/index.tsx | 11 ++ .../workspace-card/index.tsx | 70 +++++---- .../workspace-card/styles.css.ts | 99 +++++++++++++ .../workspace-card/styles.ts | 24 ++-- .../components/root-app-sidebar/index.css.ts | 19 +++ .../src/components/root-app-sidebar/index.tsx | 9 +- .../components/root-app-sidebar/user-info.tsx | 130 +++++++++++++++++ .../navigation/view/navigation-buttons.css.ts | 2 +- tests/affine-cloud/e2e/login.spec.ts | 10 +- tests/affine-local/e2e/image-preview.spec.ts | 86 ++++++----- tests/kit/utils/sidebar.ts | 4 + 20 files changed, 528 insertions(+), 372 deletions(-) create mode 100644 packages/frontend/component/src/components/card/workspace-card/styles.css.ts delete mode 100644 packages/frontend/component/src/components/card/workspace-card/styles.ts create mode 100644 packages/frontend/core/public/imgs/unknown-user.svg create mode 100644 packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.css.ts create mode 100644 packages/frontend/core/src/components/root-app-sidebar/index.css.ts create mode 100644 packages/frontend/core/src/components/root-app-sidebar/user-info.tsx diff --git a/packages/frontend/component/src/components/card/workspace-card/index.tsx b/packages/frontend/component/src/components/card/workspace-card/index.tsx index bd1c11216e..cce605e305 100644 --- a/packages/frontend/component/src/components/card/workspace-card/index.tsx +++ b/packages/frontend/component/src/components/card/workspace-card/index.tsx @@ -1,69 +1,18 @@ import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; -import { WorkspaceFlavour } from '@affine/env/workspace'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { WorkspaceFlavour } from '@affine/env/workspace'; import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons'; import type { WorkspaceMetadata } from '@toeverything/infra'; import { useCallback } from 'react'; -import { Avatar } from '../../../ui/avatar'; -import { Divider } from '../../../ui/divider'; +import { Avatar, type AvatarProps } from '../../../ui/avatar'; import { Skeleton } from '../../../ui/skeleton'; -import { Tooltip } from '../../../ui/tooltip'; -import { - StyledCard, - StyledIconContainer, - StyledSettingLink, - StyledWorkspaceInfo, - StyledWorkspaceTitle, - StyledWorkspaceTitleArea, - StyledWorkspaceType, - StyledWorkspaceTypeEllipse, - StyledWorkspaceTypeText, -} from './styles'; +import * as styles from './styles.css'; export interface WorkspaceTypeProps { flavour: WorkspaceFlavour; isOwner: boolean; } -const WorkspaceType = ({ flavour, isOwner }: WorkspaceTypeProps) => { - const t = useAFFiNEI18N(); - if (flavour === WorkspaceFlavour.LOCAL) { - return ( - - - {t['Local']()} - - ); - } - - return isOwner ? ( - - - - {t['com.affine.brand.affineCloud']()} - - - ) : ( - - - - {t['com.affine.brand.affineCloud']()} - - - - - - - - - ); -}; - export interface WorkspaceCardProps { currentWorkspaceId?: string | null; meta: WorkspaceMetadata; @@ -77,7 +26,7 @@ export interface WorkspaceCardProps { export const WorkspaceCardSkeleton = () => { return (
- +
{ width={220} style={{ marginLeft: '12px' }} /> - +
); }; +const avatarImageProps = { + style: { borderRadius: 3, overflow: 'hidden' }, +} satisfies AvatarProps['imageProps']; export const WorkspaceCard = ({ onClick, onSettingClick, @@ -101,32 +53,38 @@ export const WorkspaceCard = ({ }: WorkspaceCardProps) => { const displayName = name ?? UNTITLED_WORKSPACE_NAME; return ( - { onClick(meta); }, [onClick, meta])} - active={meta.id === currentWorkspaceId} > - - - - {displayName} + +
+
{displayName}
- + {isOwner ? null : } +
{ e.stopPropagation(); onSettingClick(meta); }} - withoutHoverStyle={true} > - - - - - - + +
+
+ + ); }; diff --git a/packages/frontend/component/src/components/card/workspace-card/styles.css.ts b/packages/frontend/component/src/components/card/workspace-card/styles.css.ts new file mode 100644 index 0000000000..20778bedbb --- /dev/null +++ b/packages/frontend/component/src/components/card/workspace-card/styles.css.ts @@ -0,0 +1,86 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +import { displayFlex, textEllipsis } from '../../../styles'; + +export const card = style({ + width: '100%', + cursor: 'pointer', + padding: '8px 12px', + borderRadius: 4, + // border: `1px solid ${borderColor}`, + boxShadow: 'inset 0 0 0 1px transparent', + ...displayFlex('flex-start', 'flex-start'), + transition: 'background .2s', + position: 'relative', + color: cssVar('textSecondaryColor'), + background: 'transparent', + display: 'flex', + alignItems: 'center', + gap: 12, + + selectors: { + '&:hover': { + background: cssVar('hoverColor'), + }, + '&[data-active="true"]': { + boxShadow: 'inset 0 0 0 1px ' + cssVar('brandColor'), + }, + }, +}); + +export const workspaceInfo = style({ + width: 0, + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 2, +}); + +export const workspaceTitle = style({ + width: 0, + flex: 1, + fontSize: cssVar('fontSm'), + fontWeight: 500, + lineHeight: '22px', + maxWidth: '190px', + color: cssVar('textPrimaryColor'), + ...textEllipsis(1), +}); + +export const actionButtons = style({ + display: 'flex', + alignItems: 'center', +}); + +export const settingButtonWrapper = style({}); + +export const settingButton = style({ + transition: 'all 0.13s ease', + width: 0, + height: 20, + overflow: 'hidden', + marginLeft: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + placeItems: 'center', + + borderRadius: 4, + boxShadow: 'none', + background: 'transparent', + cursor: 'pointer', + + selectors: { + [`.${card}:hover &`]: { + width: 20, + marginLeft: 8, + boxShadow: cssVar('shadow1'), + background: cssVar('white80'), + }, + // [`.${card}:hover &:hover`]: { + // background: cssVar('hoverColor'), + // }, + }, +}); diff --git a/packages/frontend/component/src/components/card/workspace-card/styles.ts b/packages/frontend/component/src/components/card/workspace-card/styles.ts deleted file mode 100644 index 46d48c2de2..0000000000 --- a/packages/frontend/component/src/components/card/workspace-card/styles.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { displayFlex, styled, textEllipsis } from '../../../styles'; -import { IconButton } from '../../../ui/button'; - -export const StyledWorkspaceInfo = styled('div')(() => { - return { - marginLeft: '12px', - width: '100%', - }; -}); - -export const StyledWorkspaceTitle = styled('div')(() => { - return { - fontSize: 'var(--affine-font-sm)', - fontWeight: 700, - lineHeight: '22px', - maxWidth: '190px', - color: 'var(--affine-text-primary-color)', - ...textEllipsis(1), - }; -}); - -export const StyledCard = styled('div')<{ - active?: boolean; -}>(({ active }) => { - const borderColor = active ? 'var(--affine-primary-color)' : 'transparent'; - const backgroundColor = active ? 'var(--affine-white-30)' : 'transparent'; - return { - width: '100%', - cursor: 'pointer', - padding: '12px', - borderRadius: '8px', - border: `1px solid ${borderColor}`, - ...displayFlex('flex-start', 'flex-start'), - transition: 'background .2s', - alignItems: 'center', - position: 'relative', - color: 'var(--affine-text-secondary-color)', - background: backgroundColor, - ':hover': { - background: 'var(--affine-hover-color)', - '.add-icon': { - borderColor: 'var(--affine-primary-color)', - color: 'var(--affine-primary-color)', - }, - '.setting-entry': { - opacity: 1, - pointerEvents: 'auto', - backgroundColor: 'var(--affine-white-30)', - boxShadow: 'var(--affine-shadow-1)', - ':hover': { - background: - 'linear-gradient(0deg, var(--affine-hover-color) 0%, var(--affine-hover-color) 100%), var(--affine-white-30)', - }, - }, - }, - '@media (max-width: 720px)': { - width: '100%', - }, - }; -}); - -export const StyledModalHeader = styled('div')(() => { - return { - width: '100%', - height: '72px', - position: 'absolute', - left: 0, - top: 0, - borderRadius: '24px 24px 0 0', - padding: '0 40px', - ...displayFlex('space-between', 'center'), - }; -}); - -export const StyledSettingLink = styled(IconButton)(() => { - return { - position: 'absolute', - right: '10px', - top: '10px', - opacity: 0, - borderRadius: '4px', - color: 'var(--affine-primary-color)', - pointerEvents: 'none', - transition: 'all .15s', - ':hover': { - background: 'var(--affine-hover-color)', - }, - }; -}); - -export const StyledWorkspaceType = styled('div')(() => { - return { - ...displayFlex('flex-start', 'center'), - width: '100%', - height: '20px', - }; -}); - -export const StyledWorkspaceTitleArea = styled('div')(() => { - return { - display: 'flex', - justifyContent: 'space-between', - }; -}); - -export const StyledWorkspaceTypeEllipse = styled('div')<{ - cloud?: boolean; -}>(({ cloud }) => { - return { - width: '5px', - height: '5px', - borderRadius: '50%', - background: cloud - ? 'var(--affine-palette-shape-blue)' - : 'var(--affine-palette-shape-green)', - }; -}); - -export const StyledWorkspaceTypeText = styled('div')(() => { - return { - fontSize: '12px', - fontWeight: 500, - lineHeight: '20px', - marginLeft: '4px', - color: 'var(--affine-text-secondary-color)', - }; -}); - -export const StyledIconContainer = styled('div')(() => { - return { - ...displayFlex('flex-start', 'center'), - fontSize: '14px', - gap: '8px', - color: 'var(--affine-icon-secondary)', - }; -}); diff --git a/packages/frontend/component/src/ui/avatar/style.css.ts b/packages/frontend/component/src/ui/avatar/style.css.ts index e7df958785..c34b6243e9 100644 --- a/packages/frontend/component/src/ui/avatar/style.css.ts +++ b/packages/frontend/component/src/ui/avatar/style.css.ts @@ -79,7 +79,6 @@ export const DefaultAvatarContainerStyle = style({ width: '100%', height: '100%', position: 'relative', - borderRadius: '50%', overflow: 'hidden', }); export const DefaultAvatarMiddleItemStyle = style({ @@ -155,6 +154,7 @@ export const avatarFallback = style({ width: '100%', height: '100%', borderRadius: '50%', + overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/packages/frontend/core/public/imgs/unknown-user.svg b/packages/frontend/core/public/imgs/unknown-user.svg new file mode 100644 index 0000000000..25b33b446b --- /dev/null +++ b/packages/frontend/core/public/imgs/unknown-user.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/frontend/core/src/components/app-sidebar/fallback.css.ts b/packages/frontend/core/src/components/app-sidebar/fallback.css.ts index 1250b8b585..046413fa68 100644 --- a/packages/frontend/core/src/components/app-sidebar/fallback.css.ts +++ b/packages/frontend/core/src/components/app-sidebar/fallback.css.ts @@ -1,10 +1,9 @@ import { style } from '@vanilla-extract/css'; export const fallbackStyle = style({ - margin: '5px 16px', + margin: '4px 16px', height: '100%', }); export const fallbackHeaderStyle = style({ - height: '56px', width: '100%', display: 'flex', alignItems: 'center', diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx index 4daa4c70e7..e77248ef9f 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.tsx @@ -19,7 +19,7 @@ import * as styles from './index.css'; import { UserAccountItem } from './user-account'; import { AFFiNEWorkspaceList } from './workspace-list'; -const SignInItem = () => { +export const SignInItem = () => { const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom); const setOpen = useSetAtom(authAtom); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx index bb67ce51ae..94616d703c 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx @@ -1,73 +1,8 @@ -import { IconButton } from '@affine/component/ui/button'; -import { Divider } from '@affine/component/ui/divider'; -import { Menu, MenuIcon, MenuItem } from '@affine/component/ui/menu'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { - AccountIcon, - MoreHorizontalIcon, - SignOutIcon, -} from '@blocksuite/icons'; -import { useSetAtom } from 'jotai'; -import { useCallback } from 'react'; - -import { - openSettingModalAtom, - openSignOutModalAtom, -} from '../../../../../atoms'; import { UserPlanButton } from '../../../../affine/auth/user-plan-button'; import * as styles from './index.css'; -const AccountMenu = ({ onEventEnd }: { onEventEnd?: () => void }) => { - const setSettingModalAtom = useSetAtom(openSettingModalAtom); - const setOpenSignOutModalAtom = useSetAtom(openSignOutModalAtom); - - const onOpenAccountSetting = useCallback(() => { - setSettingModalAtom(prev => ({ - ...prev, - open: true, - activeTab: 'account', - })); - }, [setSettingModalAtom]); - - const onOpenSignOutModal = useCallback(() => { - onEventEnd?.(); - setOpenSignOutModalAtom(true); - }, [onEventEnd, setOpenSignOutModalAtom]); - - const t = useAFFiNEI18N(); - - return ( -
- - - - } - data-testid="workspace-modal-account-settings-option" - onClick={onOpenAccountSetting} - > - {t['com.affine.workspace.cloud.account.settings']()} - - - - - - } - data-testid="workspace-modal-sign-out-option" - onClick={onOpenSignOutModal} - > - {t['com.affine.workspace.cloud.account.logout']()} - -
- ); -}; - export const UserAccountItem = ({ email, - onEventEnd, }: { email: string; onEventEnd?: () => void; @@ -76,21 +11,8 @@ export const UserAccountItem = ({
{email}
-
- } - contentOptions={{ - side: 'right', - sideOffset: 12, - }} - > - } - type="plain" - /> - +
); }; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts index 13fac8256b..3bd7664102 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.css.ts @@ -10,17 +10,21 @@ export const workspaceListWrapper = style({ display: 'flex', width: '100%', flexDirection: 'column', - gap: '4px', + gap: 2, }); export const workspaceType = style({ display: 'flex', alignItems: 'center', - justifyContent: 'space-between', + gap: 4, padding: '0px 12px', + fontWeight: 500, fontSize: cssVar('fontXs'), lineHeight: '20px', color: cssVar('textSecondaryColor'), }); +export const workspaceTypeIcon = style({ + color: cssVar('iconSecondary'), +}); export const scrollbar = style({ transform: 'translateX(8px)', width: '4px', diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx index 0b9940d435..4e72fce242 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/workspace-list/index.tsx @@ -8,6 +8,7 @@ import { } from '@affine/core/hooks/use-workspace-info'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { CloudWorkspaceIcon, LocalWorkspaceIcon } from '@blocksuite/icons'; import type { DragEndEvent } from '@dnd-kit/core'; import type { WorkspaceMetadata } from '@toeverything/infra'; import { useLiveData, useService, WorkspaceManager } from '@toeverything/infra'; @@ -49,6 +50,11 @@ const CloudWorkSpaceList = ({ return (
+ {t['com.affine.workspaceList.workspaceListType.cloud']()}
+ {t['com.affine.workspaceList.workspaceListType.local']()}
{ return ( <> - AFFiNE Cloud + Cloud ); }; @@ -196,21 +190,49 @@ const useSyncEngineSyncProgress = () => { ) : ( ), + active: + currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD && + (syncing || retrying || isOverCapacity), }; }; -const WorkspaceStatus = () => { - const { message, icon } = useSyncEngineSyncProgress(); +const WorkspaceInfo = ({ name }: { name: string }) => { + const { message, icon, active } = useSyncEngineSyncProgress(); + const currentWorkspace = useService(Workspace); + const isCloud = currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD; + + // to make sure that animation will play first time + const [delayActive, setDelayActive] = useState(false); + useEffect(() => { + setDelayActive(active); + }, [active]); return ( -
- - {icon} - +
+
+
+
+ {name} +
+
+ {isCloud ? : } +
+
+ + {/* when syncing/offline/... */} +
+
+ {icon} +
+
+
); }; +const avatarImageProps = { + style: { borderRadius: 3 }, +} satisfies AvatarProps['imageProps']; export const WorkspaceCard = forwardRef< HTMLDivElement, HTMLAttributes @@ -227,7 +249,8 @@ export const WorkspaceCard = forwardRef< const name = information?.name ?? UNTITLED_WORKSPACE_NAME; return ( - - - - {name} - - - - + +
); }); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.css.ts new file mode 100644 index 0000000000..528cabf5e7 --- /dev/null +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.css.ts @@ -0,0 +1,99 @@ +import { cssVar } from '@toeverything/theme'; +import { globalStyle, style } from '@vanilla-extract/css'; + +const wsSlideAnim = { + ease: 'cubic-bezier(.45,.21,0,1)', + duration: '0.5s', + delay: '0.23s', +}; + +export const container = style({ + height: '50px', + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '0 6px', + borderRadius: 4, + outline: 'none', + width: '100%', + maxWidth: 500, + color: cssVar('textPrimaryColor'), + ':hover': { + cursor: 'pointer', + background: cssVar('hoverColor'), + }, +}); + +export const workspaceInfoSlider = style({ + height: 42, + overflow: 'hidden', +}); +export const workspaceInfoSlide = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + transform: 'translateY(0)', + transition: `transform ${wsSlideAnim.duration} ${wsSlideAnim.ease} ${wsSlideAnim.delay}`, + selectors: { + [`.${workspaceInfoSlider}[data-active="true"] &`]: { + transform: 'translateY(-42px)', + }, + }, +}); +export const workspaceInfo = style({ + width: '100%', + flexGrow: 1, + overflow: 'hidden', + height: 42, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + transition: `opacity ${wsSlideAnim.duration} ${wsSlideAnim.ease} ${wsSlideAnim.delay}`, + + selectors: { + [`.${workspaceInfoSlider}[data-active="true"] &[data-type="normal"]`]: { + opacity: 0, + }, + [`.${workspaceInfoSlider}[data-active="false"] &[data-type="events"]`]: { + opacity: 0, + }, + }, +}); + +export const workspaceName = style({ + fontSize: cssVar('fontSm'), + lineHeight: '22px', + fontWeight: 500, + userSelect: 'none', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const workspaceStatus = style({ + display: 'flex', + gap: 2, + alignItems: 'center', + fontSize: cssVar('fontXs'), + lineHeight: '20px', + fontWeight: 400, + color: cssVar('black50'), +}); +globalStyle(`.${workspaceStatus} svg`, { + width: 16, + height: 16, + color: cssVar('iconSecondary'), +}); + +export const workspaceActiveStatus = style({ + display: 'flex', + gap: 2, + alignItems: 'center', + fontSize: cssVar('fontSm'), + lineHeight: '22px', + color: cssVar('textSecondaryColor'), +}); +globalStyle(`.${workspaceActiveStatus} svg`, { + width: 16, + height: 16, +}); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.ts index ea8f62c6de..55d0fb3ec6 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/styles.ts @@ -1,16 +1,18 @@ import { displayFlex, styled, textEllipsis } from '@affine/component'; +import { cssVar } from '@toeverything/theme'; export const StyledSelectorContainer = styled('div')({ - height: '58px', + height: '50px', display: 'flex', alignItems: 'center', padding: '0 6px', borderRadius: '8px', outline: 'none', - width: '100%', - color: 'var(--affine-text-primary-color)', + width: 'fit-content', + maxWidth: '100%', + color: cssVar('textPrimaryColor'), ':hover': { cursor: 'pointer', - background: 'var(--affine-hover-color)', + background: cssVar('hoverColor'), }, }); @@ -23,8 +25,9 @@ export const StyledSelectorWrapper = styled('div')(() => { }); export const StyledWorkspaceName = styled('div')(() => { return { - lineHeight: '24px', - fontWeight: 600, + fontSize: cssVar('fontSm'), + lineHeight: '22px', + fontWeight: 500, userSelect: 'none', ...textEllipsis(1), marginLeft: '4px', @@ -35,17 +38,16 @@ export const StyledWorkspaceStatus = styled('div')(() => { return { height: '22px', ...displayFlex('flex-start', 'center'), - fontSize: 'var(--affine-font-sm)', - color: 'var(--affine-text-secondary-color)', + fontSize: cssVar('fontXs'), + color: cssVar('black50'), userSelect: 'none', padding: '0 4px', gap: '4px', zIndex: '1', svg: { - color: 'var(--affine-icon-color)', - fontSize: 'var(--affine-font-base)', + color: cssVar('iconSecondary'), '&[data-warning-color="true"]': { - color: 'var(--affine-error-color)', + color: cssVar('errorColor'), }, }, }; diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.css.ts b/packages/frontend/core/src/components/root-app-sidebar/index.css.ts new file mode 100644 index 0000000000..1b4d57eee5 --- /dev/null +++ b/packages/frontend/core/src/components/root-app-sidebar/index.css.ts @@ -0,0 +1,19 @@ +import { style } from '@vanilla-extract/css'; + +export const workspaceAndUserWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, +}); + +export const workspaceWrapper = style({ + width: 0, + flex: 1, +}); + +export const userInfoWrapper = style({ + flexShrink: 0, + width: 28, + height: 28, +}); diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx index 194ed8eeb4..e6dca11b8d 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -42,8 +42,10 @@ import { AddFavouriteButton } from '../pure/workspace-slider-bar/favorite/add-fa import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list'; import { WorkspaceSelector } from '../workspace-selector'; import ImportPage from './import-page'; +import { workspaceAndUserWrapper, workspaceWrapper } from './index.css'; import { AppSidebarJournalButton } from './journal-button'; import { UpdaterButton } from './updater-button'; +import { UserInfo } from './user-info'; export type RootAppSidebarProps = { isPublicWorkspace: boolean; @@ -179,7 +181,12 @@ export const RootAppSidebar = ({ titles={deletePageTitles} /> - +
+
+ +
+ +
{ + const { status } = useSession(); + const isAuthenticated = status === 'authenticated'; + return isAuthenticated ? : ; +}; + +const AuthorizedUserInfo = () => { + const user = useCurrentUser(); + return ( + }> + + + ); +}; + +const UnauthorizedUserInfo = () => { + const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom); + const setOpen = useSetAtom(authAtom); + + const openSignInModal = useCallback(() => { + if (!runtimeConfig.enableCloud) setDisableCloudOpen(true); + else setOpen(state => ({ ...state, openModal: true })); + }, [setDisableCloudOpen, setOpen]); + + return ( + + ); +}; + +const AccountMenu = () => { + const setSettingModalAtom = useSetAtom(openSettingModalAtom); + const setOpenSignOutModalAtom = useSetAtom(openSignOutModalAtom); + + const onOpenAccountSetting = useCallback(() => { + setSettingModalAtom(prev => ({ + ...prev, + open: true, + activeTab: 'account', + })); + }, [setSettingModalAtom]); + + const onOpenSignOutModal = useCallback(() => { + setOpenSignOutModalAtom(true); + }, [setOpenSignOutModalAtom]); + + const t = useAFFiNEI18N(); + + return ( + <> + + + + } + data-testid="workspace-modal-account-settings-option" + onClick={onOpenAccountSetting} + > + {t['com.affine.workspace.cloud.account.settings']()} + + + + + + } + data-testid="workspace-modal-sign-out-option" + onClick={onOpenSignOutModal} + > + {t['com.affine.workspace.cloud.account.logout']()} + + + ); +}; + +const OperationMenu = () => { + // TODO: display usage progress bar + const StorageUsage = null; + + return ( + <> + {StorageUsage} + + + ); +}; diff --git a/packages/frontend/core/src/modules/navigation/view/navigation-buttons.css.ts b/packages/frontend/core/src/modules/navigation/view/navigation-buttons.css.ts index 7b91edb5d6..941dd3c25d 100644 --- a/packages/frontend/core/src/modules/navigation/view/navigation-buttons.css.ts +++ b/packages/frontend/core/src/modules/navigation/view/navigation-buttons.css.ts @@ -3,7 +3,7 @@ import { style } from '@vanilla-extract/css'; export const container = style({ display: 'flex', alignItems: 'center', - columnGap: '32px', + columnGap: '8px', }); export const button = style({ diff --git a/tests/affine-cloud/e2e/login.spec.ts b/tests/affine-cloud/e2e/login.spec.ts index 6ede51db77..9ea7e65f02 100644 --- a/tests/affine-cloud/e2e/login.spec.ts +++ b/tests/affine-cloud/e2e/login.spec.ts @@ -11,6 +11,7 @@ import { clickSideBarAllPageButton, clickSideBarCurrentWorkspaceBanner, clickSideBarSettingButton, + clickSideBarUseAvatar, } from '@affine-test/kit/utils/sidebar'; import { createLocalWorkspace } from '@affine-test/kit/utils/workspace'; import { expect } from '@playwright/test'; @@ -69,14 +70,13 @@ test.describe('login first', () => { ); await clickSideBarAllPageButton(page); const currentUrl = page.url(); - await clickSideBarCurrentWorkspaceBanner(page); - await page.getByTestId('workspace-modal-account-option').click(); + await clickSideBarUseAvatar(page); await page.getByTestId('workspace-modal-sign-out-option').click(); await page.getByTestId('confirm-sign-out-button').click(); await page.reload(); - await clickSideBarCurrentWorkspaceBanner(page); - const signInButton = page.getByTestId('cloud-signin-button'); - await expect(signInButton).toBeVisible(); + await clickSideBarUseAvatar(page); + const authModal = page.getByTestId('auth-modal'); + await expect(authModal).toBeVisible(); expect(page.url()).toBe(currentUrl); }); diff --git a/tests/affine-local/e2e/image-preview.spec.ts b/tests/affine-local/e2e/image-preview.spec.ts index 4ed72ce702..5845869ba9 100644 --- a/tests/affine-local/e2e/image-preview.spec.ts +++ b/tests/affine-local/e2e/image-preview.spec.ts @@ -33,7 +33,8 @@ async function importImage(page: Page, url: string) { }, [url] ); - await page.waitForTimeout(500); + // TODO: wait for image to be loaded more reliably + await page.waitForTimeout(1000); } async function closeImagePreviewModal(page: Page) { @@ -53,7 +54,7 @@ test('image preview should be shown', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); const locator = page.getByTestId('image-preview-modal'); await expect(locator).toBeVisible(); await closeImagePreviewModal(page); @@ -70,11 +71,12 @@ test('image go left and right', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); await closeImagePreviewModal(page); @@ -87,11 +89,12 @@ test('image go left and right', async ({ page }) => { } const locator = page.getByTestId('image-preview-modal'); await expect(locator).toBeHidden(); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(1000); { - const newBlobId = (await locator - .locator('img[data-blob-id]') + const newBlobId = (await page + .getByTestId('image-preview-modal') + .locator('img') .first() .getAttribute('data-blob-id')) as string; expect(newBlobId).not.toBe(blobId); @@ -99,8 +102,9 @@ test('image go left and right', async ({ page }) => { await page.keyboard.press('ArrowRight'); await page.waitForTimeout(1000); { - const newBlobId = (await locator - .locator('img[data-blob-id]') + const newBlobId = (await page + .getByTestId('image-preview-modal') + .locator('img') .first() .getAttribute('data-blob-id')) as string; expect(newBlobId).toBe(blobId); @@ -117,11 +121,12 @@ test('image able to zoom in and out with mouse scroll', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); } @@ -170,11 +175,12 @@ test('image able to zoom in and out with button click', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); } @@ -216,11 +222,12 @@ test('image should able to go left and right by buttons', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); await closeImagePreviewModal(page); @@ -232,7 +239,7 @@ test('image should able to go left and right by buttons', async ({ page }) => { await importImage(page, 'http://localhost:8081/affine-preview.png'); } const locator = page.getByTestId('image-preview-modal'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await expect(locator).toBeVisible(); { const newBlobId = (await locator @@ -268,11 +275,12 @@ test('image able to fit to screen by button', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); } @@ -325,11 +333,12 @@ test('image able to reset zoom to 100%', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); } @@ -378,11 +387,12 @@ test('image able to copy to clipboard', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); } @@ -407,11 +417,12 @@ test('image able to download', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); } @@ -437,11 +448,12 @@ test('image should only able to move when image is larger than viewport', async await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); } @@ -494,11 +506,12 @@ test('image should able to delete and when delete, it will move to previous/next await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); await closeImagePreviewModal(page); @@ -510,7 +523,7 @@ test('image should able to delete and when delete, it will move to previous/next await importImage(page, 'http://localhost:8081/affine-preview.png'); } const locator = page.getByTestId('image-preview-modal'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await expect(locator).toBeVisible(); // ensure the new image was imported await page.waitForTimeout(1000); @@ -533,7 +546,7 @@ test('image should able to delete and when delete, it will move to previous/next await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/affine-preview.png'); } - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await locator.getByTestId('next-image-button').click(); await page.waitForTimeout(1000); { @@ -569,11 +582,12 @@ test('tooltips for all buttons should be visible when hovering', async ({ await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); await page.waitForTimeout(500); blobId = (await page + .getByTestId('image-preview-modal') .locator('img') - .nth(1) + .first() .getAttribute('data-blob-id')) as string; expect(blobId).toBeTruthy(); } @@ -662,7 +676,7 @@ test('keypress esc should close the modal', async ({ page }) => { await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); const locator = page.getByTestId('image-preview-modal'); await expect(locator).toBeVisible(); await page.keyboard.press('Escape'); @@ -680,7 +694,7 @@ test('when mouse moves outside, the modal should be closed', async ({ await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); const locator = page.getByTestId('image-preview-modal'); await expect(locator).toBeVisible(); // animation delay @@ -701,14 +715,14 @@ test('caption should be visible and different styles were applied if image zoome await title.click(); await page.keyboard.press('Enter'); await importImage(page, 'http://localhost:8081/large-image.png'); - await page.locator('img').first().hover(); + await page.locator('affine-page-image').first().hover(); await page .locator('.embed-editing-state') .locator('icon-button') .nth(1) .click(); await page.getByPlaceholder('Write a caption').fill(sampleCaption); - await page.locator('img').first().dblclick(); + await page.locator('affine-page-image').first().dblclick(); const locator = page.getByTestId('image-preview-modal'); await expect(locator).toBeVisible(); await page.waitForTimeout(1000); diff --git a/tests/kit/utils/sidebar.ts b/tests/kit/utils/sidebar.ts index b9e685b0a5..7ac33a9a9d 100644 --- a/tests/kit/utils/sidebar.ts +++ b/tests/kit/utils/sidebar.ts @@ -12,6 +12,10 @@ export async function clickSideBarCurrentWorkspaceBanner(page: Page) { return page.getByTestId('current-workspace').click(); } +export async function clickSideBarUseAvatar(page: Page) { + return page.getByTestId('sidebar-user-avatar').click(); +} + export async function clickNewPageButton(page: Page) { return page.getByTestId('sidebar-new-page-button').click(); } From b8e6d7d6cb5014873f1fdde53d1c6cf6facf9de7 Mon Sep 17 00:00:00 2001 From: liuyi Date: Tue, 26 Mar 2024 13:49:24 +0800 Subject: [PATCH 12/12] chore(server): cache blob list result (#6297) --- .../server/src/core/storage/wrappers/blob.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/backend/server/src/core/storage/wrappers/blob.ts b/packages/backend/server/src/core/storage/wrappers/blob.ts index 424f5d1a6b..84608a7aab 100644 --- a/packages/backend/server/src/core/storage/wrappers/blob.ts +++ b/packages/backend/server/src/core/storage/wrappers/blob.ts @@ -1,13 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import type { - BlobInputType, - EventPayload, - StorageProvider, -} from '../../../fundamentals'; import { + BlobInputType, + Cache, EventEmitter, + type EventPayload, + ListObjectsMetadata, OnEvent, + StorageProvider, StorageProviderFactory, } from '../../../fundamentals'; @@ -17,13 +17,15 @@ export class WorkspaceBlobStorage { constructor( private readonly event: EventEmitter, - private readonly storageFactory: StorageProviderFactory + private readonly storageFactory: StorageProviderFactory, + @Inject(forwardRef(() => Cache)) private readonly cache: Cache ) { this.provider = this.storageFactory.create('blob'); } async put(workspaceId: string, key: string, blob: BlobInputType) { await this.provider.put(`${workspaceId}/${key}`, blob); + await this.cache.delete(`blobs:${workspaceId}`); } async get(workspaceId: string, key: string) { @@ -31,6 +33,16 @@ export class WorkspaceBlobStorage { } async list(workspaceId: string) { + const cachedList = await this.cache.list( + `blobs:${workspaceId}`, + 0, + -1 + ); + + if (cachedList.length > 0) { + return cachedList; + } + const blobs = await this.provider.list(workspaceId + '/'); blobs.forEach(item => { @@ -38,6 +50,8 @@ export class WorkspaceBlobStorage { item.key = item.key.slice(workspaceId.length + 1); }); + await this.cache.pushBack(`blobs:${workspaceId}`, ...blobs); + return blobs; }