diff --git a/.eslintrc.js b/.eslintrc.js index 0584f1209c..fbfe60b9d3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,17 +31,6 @@ const createPattern = packageName => [ message: 'Use `useNavigateHelper` instead', importNames: ['useNavigate'], }, - { - group: ['next-auth/react'], - message: "Import hooks from 'use-current-user.tsx'", - // useSession is type unsafe - importNames: ['useSession'], - }, - { - group: ['next-auth/react'], - message: "Import hooks from 'cloud-utils.ts'", - importNames: ['signIn', 'signOut'], - }, { group: ['yjs'], message: 'Do not use this API because it has a bug', @@ -179,17 +168,6 @@ const config = { message: 'Use `useNavigateHelper` instead', importNames: ['useNavigate'], }, - { - group: ['next-auth/react'], - message: "Import hooks from 'use-current-user.tsx'", - // useSession is type unsafe - importNames: ['useSession'], - }, - { - group: ['next-auth/react'], - message: "Import hooks from 'cloud-utils.ts'", - importNames: ['signIn', 'signOut'], - }, { group: ['yjs'], message: 'Do not use this API because it has a bug', diff --git a/.github/helm/affine/templates/ingress.yaml b/.github/helm/affine/templates/ingress.yaml index 5a391029d8..3c74cfdb06 100644 --- a/.github/helm/affine/templates/ingress.yaml +++ b/.github/helm/affine/templates/ingress.yaml @@ -60,6 +60,13 @@ spec: name: affine-graphql port: number: {{ .Values.graphql.service.port }} + - path: /oauth + pathType: Prefix + backend: + service: + name: affine-graphql + port: + number: {{ .Values.graphql.service.port }} - path: / pathType: Prefix backend: diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index dc66c88f61..6de163706b 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -336,17 +336,11 @@ jobs: env: PGPASSWORD: affine - - name: Generate prisma client + - name: Run init-db script run: | yarn workspace @affine/server exec prisma generate yarn workspace @affine/server exec prisma db push - env: - DATABASE_URL: postgresql://affine:affine@localhost:5432/affine - - - name: Run init-db script - run: | yarn workspace @affine/server data-migration run - yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts env: DATABASE_URL: postgresql://affine:affine@localhost:5432/affine @@ -435,17 +429,11 @@ jobs: env: PGPASSWORD: affine - - name: Generate prisma client + - name: Run init-db script run: | yarn workspace @affine/server exec prisma generate yarn workspace @affine/server exec prisma db push - env: - DATABASE_URL: postgresql://affine:affine@localhost:5432/affine - - - name: Run init-db script - run: | yarn workspace @affine/server data-migration run - yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts - name: ${{ matrix.tests.name }} run: | diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index c4c502d634..f514dd4950 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -351,7 +351,7 @@ jobs: RELEASE_VERSION: ${{ needs.before-make.outputs.RELEASE_VERSION }} - name: Create Release Draft if: ${{ github.ref_type == 'tag' }} - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: name: ${{ needs.before-make.outputs.RELEASE_VERSION }} body: '' @@ -367,7 +367,7 @@ jobs: ./*.yml - name: Create Nightly Release Draft if: ${{ github.ref_type == 'branch' }} - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} with: diff --git a/docs/developing-server.md b/docs/developing-server.md index 2c044f25b1..6df00f5fdf 100644 --- a/docs/developing-server.md +++ b/docs/developing-server.md @@ -49,22 +49,18 @@ postgres=# \du ### Set the following config to `packages/backend/server/.env` +In the following setup, we assume you have postgres server running at localhost:5432 and mailhog running at localhost:1025. + +When logging in via email, you will see the mail arriving at localhost:8025 in a browser. + ``` DATABASE_URL="postgresql://affine:affine@localhost:5432/affine" -NEXTAUTH_URL="http://localhost:8080/" -``` - -You may need additional env for auth login. You may want to put your own one if you are not part of the AFFiNE team - -For email login & password, please refer to https://nodemailer.com/usage/using-gmail/ - -``` -MAILER_SENDER= -MAILER_USER= -MAILER_PASSWORD= -OAUTH_GOOGLE_ENABLED="true" -OAUTH_GOOGLE_CLIENT_ID= -OAUTH_GOOGLE_CLIENT_SECRET= +NEXTAUTH_URL="http://localhost:8080" +MAILER_SENDER="noreply@toeverything.info" +MAILER_USER="auth" +MAILER_PASSWORD="auth" +MAILER_HOST="localhost" +MAILER_PORT="1025" ``` ## Prepare prisma diff --git a/package.json b/package.json index 8556fef889..ef7d1ec4e5 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@faker-js/faker": "^8.4.1", "@istanbuljs/schema": "^0.1.3", "@magic-works/i18n-codegen": "^0.5.0", - "@nx/vite": "18.0.7", + "@nx/vite": "18.0.8", "@playwright/test": "^1.41.2", "@taplo/cli": "^0.7.0", "@testing-library/react": "^14.2.1", @@ -103,7 +103,7 @@ "ts-node": "^10.9.2", "typescript": "^5.3.3", "vite": "^5.1.4", - "vite-plugin-istanbul": "^5.0.0", + "vite-plugin-istanbul": "^6.0.0", "vite-plugin-static-copy": "^1.0.1", "vitest": "1.3.1", "vitest-fetch-mock": "^0.2.2", @@ -167,7 +167,6 @@ "unbox-primitive": "npm:@nolyfill/unbox-primitive@latest", "which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest", "which-typed-array": "npm:@nolyfill/which-typed-array@latest", - "next-auth@^4.24.5": "patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch", "@reforged/maker-appimage/@electron-forge/maker-base": "7.3.0", "macos-alias": "npm:@napi-rs/macos-alias@latest", "fs-xattr": "npm:@napi-rs/xattr@latest", diff --git a/packages/backend/server/migrations/20240228065558_new_auth/migration.sql b/packages/backend/server/migrations/20240228065558_new_auth/migration.sql new file mode 100644 index 0000000000..303cd422f6 --- /dev/null +++ b/packages/backend/server/migrations/20240228065558_new_auth/migration.sql @@ -0,0 +1,70 @@ +-- DropForeignKey +ALTER TABLE "accounts" DROP CONSTRAINT "accounts_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "sessions" DROP CONSTRAINT "sessions_user_id_fkey"; + +-- CreateTable +CREATE TABLE "user_connected_accounts" ( + "id" VARCHAR(36) NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "provider" VARCHAR NOT NULL, + "provider_account_id" VARCHAR NOT NULL, + "scope" TEXT, + "access_token" TEXT, + "refresh_token" TEXT, + "expires_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "user_connected_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "multiple_users_sessions" ( + "id" VARCHAR(36) NOT NULL, + "expires_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "multiple_users_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_sessions" ( + "id" VARCHAR(36) NOT NULL, + "session_id" VARCHAR(36) NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "expires_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification_tokens" ( + "token" VARCHAR(36) NOT NULL, + "type" SMALLINT NOT NULL, + "credential" TEXT, + "expiresAt" TIMESTAMPTZ(6) NOT NULL +); + +-- CreateIndex +CREATE INDEX "user_connected_accounts_user_id_idx" ON "user_connected_accounts"("user_id"); + +-- CreateIndex +CREATE INDEX "user_connected_accounts_provider_account_id_idx" ON "user_connected_accounts"("provider_account_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_sessions_session_id_user_id_key" ON "user_sessions"("session_id", "user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "verification_tokens_type_token_key" ON "verification_tokens"("type", "token"); + +-- AddForeignKey +ALTER TABLE "user_connected_accounts" ADD CONSTRAINT "user_connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "multiple_users_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/migrations/20240313033631_user_registered_flag/migration.sql b/packages/backend/server/migrations/20240313033631_user_registered_flag/migration.sql new file mode 100644 index 0000000000..aba95ca449 --- /dev/null +++ b/packages/backend/server/migrations/20240313033631_user_registered_flag/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "registered" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index d6db32680d..285f2cc695 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -45,11 +45,11 @@ "@opentelemetry/exporter-zipkin": "^1.21.0", "@opentelemetry/host-metrics": "^0.35.0", "@opentelemetry/instrumentation": "^0.49.0", - "@opentelemetry/instrumentation-graphql": "^0.37.0", + "@opentelemetry/instrumentation-graphql": "^0.38.0", "@opentelemetry/instrumentation-http": "^0.49.0", - "@opentelemetry/instrumentation-ioredis": "^0.37.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.34.0", - "@opentelemetry/instrumentation-socket.io": "^0.36.0", + "@opentelemetry/instrumentation-ioredis": "^0.38.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.35.0", + "@opentelemetry/instrumentation-socket.io": "^0.37.0", "@opentelemetry/resources": "^1.21.0", "@opentelemetry/sdk-metrics": "^1.21.0", "@opentelemetry/sdk-node": "^0.49.0", @@ -74,7 +74,6 @@ "nanoid": "^5.0.6", "nest-commander": "^3.12.5", "nestjs-throttler-storage-redis": "^0.4.1", - "next-auth": "^4.24.5", "nodemailer": "^6.9.10", "on-headers": "^1.0.2", "parse-duration": "^1.1.0", @@ -143,7 +142,8 @@ "MAILER_USER": "noreply@toeverything.info", "MAILER_PASSWORD": "affine", "MAILER_SENDER": "noreply@toeverything.info", - "FEATURES_EARLY_ACCESS_PREVIEW": "false" + "FEATURES_EARLY_ACCESS_PREVIEW": "false", + "DEPLOYMENT_TYPE": "affine" } }, "nodemonConfig": { diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index ffc5be0b57..508f4052c5 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -10,28 +10,83 @@ datasource db { } model User { - id String @id @default(uuid()) @db.VarChar - name String - email String @unique - emailVerified DateTime? @map("email_verified") - // image field is for the next-auth - avatarUrl String? @map("avatar_url") @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + id String @id @default(uuid()) @db.VarChar + name String + email String @unique + emailVerifiedAt DateTime? @map("email_verified") + avatarUrl String? @map("avatar_url") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) /// Not available if user signed up through OAuth providers - password String? @db.VarChar + password String? @db.VarChar + /// Indicate whether the user finished the signup progress. + /// for example, the value will be false if user never registered and invited into a workspace by others. + registered Boolean @default(true) - accounts Account[] - sessions Session[] features UserFeatures[] customer UserStripeCustomer? subscription UserSubscription? invoices UserInvoice[] workspacePermissions WorkspaceUserPermission[] pagePermissions WorkspacePageUserPermission[] + connectedAccounts ConnectedAccount[] + sessions UserSession[] @@map("users") } +model ConnectedAccount { + id String @id @default(uuid()) @db.VarChar(36) + userId String @map("user_id") @db.VarChar(36) + provider String @db.VarChar + providerAccountId String @map("provider_account_id") @db.VarChar + scope String? @db.Text + accessToken String? @map("access_token") @db.Text + refreshToken String? @map("refresh_token") @db.Text + expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([providerAccountId]) + @@map("user_connected_accounts") +} + +model Session { + id String @id @default(uuid()) @db.VarChar(36) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + userSessions UserSession[] + + @@map("multiple_users_sessions") +} + +model UserSession { + id String @id @default(uuid()) @db.VarChar(36) + sessionId String @map("session_id") @db.VarChar(36) + userId String @map("user_id") @db.VarChar(36) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([sessionId, userId]) + @@map("user_sessions") +} + +model VerificationToken { + token String @db.VarChar(36) + type Int @db.SmallInt + credential String? @db.Text + expiresAt DateTime @db.Timestamptz(6) + + @@unique([type, token]) + @@map("verification_tokens") +} + model Workspace { id String @id @default(uuid()) @db.VarChar public Boolean @@ -186,7 +241,7 @@ model Features { @@map("features") } -model Account { +model DeprecatedNextAuthAccount { id String @id @default(cuid()) userId String @map("user_id") type String @@ -200,23 +255,20 @@ model Account { id_token String? @db.Text session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([provider, providerAccountId]) @@map("accounts") } -model Session { +model DeprecatedNextAuthSession { id String @id @default(cuid()) sessionToken String @unique @map("session_token") userId String @map("user_id") expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("sessions") } -model VerificationToken { +model DeprecatedNextAuthVerificationToken { identifier String token String @unique expires DateTime diff --git a/packages/backend/server/scripts/init-db.ts b/packages/backend/server/scripts/init-db.ts deleted file mode 100644 index 8e850ea716..0000000000 --- a/packages/backend/server/scripts/init-db.ts +++ /dev/null @@ -1,37 +0,0 @@ -import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' }; -import { hash } from '@node-rs/argon2'; -import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); - -async function main() { - await prisma.user.create({ - data: { - ...userA, - password: await hash(userA.password), - features: { - create: { - reason: 'created by api sign up', - activated: true, - feature: { - connect: { - feature_version: { - feature: 'free_plan_v1', - version: 1, - }, - }, - }, - }, - }, - }, - }); -} - -main() - .then(async () => { - await prisma.$disconnect(); - }) - .catch(async e => { - console.error(e); - await prisma.$disconnect(); - process.exit(1); - }); diff --git a/packages/backend/server/src/app.controller.ts b/packages/backend/server/src/app.controller.ts index 6a217ef3eb..1f483096f7 100644 --- a/packages/backend/server/src/app.controller.ts +++ b/packages/backend/server/src/app.controller.ts @@ -1,11 +1,13 @@ import { Controller, Get } from '@nestjs/common'; +import { Public } from './core/auth'; import { Config } from './fundamentals/config'; @Controller('/') export class AppController { constructor(private readonly config: Config) {} + @Public() @Get() info() { return { diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index dd61261051..f5f44aaeb0 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -1,20 +1,20 @@ import { join } from 'node:path'; import { Logger, Module } from '@nestjs/common'; -import { APP_INTERCEPTOR } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import { get } from 'lodash-es'; import { AppController } from './app.controller'; -import { AuthModule } from './core/auth'; +import { AuthGuard, AuthModule } from './core/auth'; import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config'; import { DocModule } from './core/doc'; import { FeatureModule } from './core/features'; import { QuotaModule } from './core/quota'; import { StorageModule } from './core/storage'; import { SyncModule } from './core/sync'; -import { UsersModule } from './core/users'; +import { UserModule } from './core/user'; import { WorkspaceModule } from './core/workspaces'; import { getOptionalModuleMetadata } from './fundamentals'; import { CacheInterceptor, CacheModule } from './fundamentals/cache'; @@ -25,14 +25,14 @@ import { } from './fundamentals/config'; import { EventModule } from './fundamentals/event'; import { GqlModule } from './fundamentals/graphql'; +import { HelpersModule } from './fundamentals/helpers'; import { MailModule } from './fundamentals/mailer'; import { MetricsModule } from './fundamentals/metrics'; import { PrismaModule } from './fundamentals/prisma'; -import { SessionModule } from './fundamentals/session'; import { StorageProviderModule } from './fundamentals/storage'; import { RateLimiterModule } from './fundamentals/throttler'; import { WebSocketModule } from './fundamentals/websocket'; -import { pluginsMap } from './plugins'; +import { REGISTERED_PLUGINS } from './plugins'; export const FunctionalityModules = [ ConfigModule.forRoot(), @@ -42,9 +42,9 @@ export const FunctionalityModules = [ PrismaModule, MetricsModule, RateLimiterModule, - SessionModule, MailModule, StorageProviderModule, + HelpersModule, ]; export class AppModuleBuilder { @@ -109,6 +109,10 @@ export class AppModuleBuilder { provide: APP_INTERCEPTOR, useClass: CacheInterceptor, }, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, ], imports: this.modules, controllers: this.config.isSelfhosted ? [] : [AppController], @@ -141,7 +145,7 @@ function buildAppModule() { WebSocketModule, GqlModule, StorageModule, - UsersModule, + UserModule, WorkspaceModule, FeatureModule, QuotaModule @@ -157,7 +161,7 @@ function buildAppModule() { // plugin modules AFFiNE.plugins.enabled.forEach(name => { - const plugin = pluginsMap.get(name as AvailablePlugins); + const plugin = REGISTERED_PLUGINS.get(name as AvailablePlugins); if (!plugin) { throw new Error(`Unknown plugin ${name}`); } diff --git a/packages/backend/server/src/config/affine.env.ts b/packages/backend/server/src/config/affine.env.ts index 8b4aadc04d..e0e3f0799b 100644 --- a/packages/backend/server/src/config/affine.env.ts +++ b/packages/backend/server/src/config/affine.env.ts @@ -7,12 +7,10 @@ AFFiNE.ENV_MAP = { DATABASE_URL: 'db.url', ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'], CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'], - OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'], - OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId', - OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret', - OAUTH_GITHUB_ENABLED: ['auth.oauthProviders.github.enabled', 'boolean'], - OAUTH_GITHUB_CLIENT_ID: 'auth.oauthProviders.github.clientId', - OAUTH_GITHUB_CLIENT_SECRET: 'auth.oauthProviders.github.clientSecret', + OAUTH_GOOGLE_CLIENT_ID: 'plugins.oauth.providers.google.clientId', + OAUTH_GOOGLE_CLIENT_SECRET: 'plugins.oauth.providers.google.clientSecret', + OAUTH_GITHUB_CLIENT_ID: 'plugins.oauth.providers.github.clientId', + OAUTH_GITHUB_CLIENT_SECRET: 'plugins.oauth.providers.github.clientSecret', MAILER_HOST: 'mailer.host', MAILER_PORT: ['mailer.port', 'int'], MAILER_USER: 'mailer.auth.user', diff --git a/packages/backend/server/src/config/affine.self.ts b/packages/backend/server/src/config/affine.self.ts index 96cf69c842..157ee2360c 100644 --- a/packages/backend/server/src/config/affine.self.ts +++ b/packages/backend/server/src/config/affine.self.ts @@ -40,6 +40,7 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) { AFFiNE.plugins.use('redis'); AFFiNE.plugins.use('payment'); +AFFiNE.plugins.use('oauth'); if (AFFiNE.deploy) { AFFiNE.mailer = { diff --git a/packages/backend/server/src/config/affine.ts b/packages/backend/server/src/config/affine.ts index d56a486529..84b82036f7 100644 --- a/packages/backend/server/src/config/affine.ts +++ b/packages/backend/server/src/config/affine.ts @@ -115,3 +115,27 @@ AFFiNE.plugins.use('payment', { // /* Update the provider of storages */ // AFFiNE.storage.storages.blob.provider = 'r2'; // AFFiNE.storage.storages.avatar.provider = 'r2'; +// +// /* OAuth Plugin */ +// AFFiNE.plugins.use('oauth', { +// providers: { +// github: { +// clientId: '', +// clientSecret: '', +// // See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps +// args: { +// scope: 'user', +// }, +// }, +// google: { +// clientId: '', +// clientSecret: '', +// args: { +// // See https://developers.google.com/identity/protocols/oauth2 +// scope: 'openid email profile', +// promot: 'select_account', +// access_type: 'offline', +// }, +// }, +// }, +// }); diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts new file mode 100644 index 0000000000..e2ffed2525 --- /dev/null +++ b/packages/backend/server/src/core/auth/controller.ts @@ -0,0 +1,213 @@ +import { randomUUID } from 'node:crypto'; + +import { + BadRequestException, + Body, + Controller, + Get, + Header, + Post, + Query, + Req, + Res, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; + +import { + Config, + PaymentRequiredException, + URLHelper, +} from '../../fundamentals'; +import { UserService } from '../user'; +import { validators } from '../utils/validators'; +import { CurrentUser } from './current-user'; +import { Public } from './guard'; +import { AuthService, parseAuthUserSeqNum } from './service'; +import { TokenService, TokenType } from './token'; + +class SignInCredential { + email!: string; + password?: string; +} + +@Controller('/api/auth') +export class AuthController { + constructor( + private readonly config: Config, + private readonly url: URLHelper, + private readonly auth: AuthService, + private readonly user: UserService, + private readonly token: TokenService + ) {} + + @Public() + @Post('/sign-in') + @Header('content-type', 'application/json') + async signIn( + @Req() req: Request, + @Res() res: Response, + @Body() credential: SignInCredential, + @Query('redirect_uri') redirectUri = this.url.home + ) { + validators.assertValidEmail(credential.email); + const canSignIn = await this.auth.canSignIn(credential.email); + if (!canSignIn) { + throw new PaymentRequiredException( + `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information` + ); + } + + if (credential.password) { + validators.assertValidPassword(credential.password); + const user = await this.auth.signIn( + credential.email, + credential.password + ); + + await this.auth.setCookie(req, res, user); + res.send(user); + } else { + // send email magic link + const user = await this.user.findUserByEmail(credential.email); + const result = await this.sendSignInEmail( + { email: credential.email, signUp: !user }, + redirectUri + ); + + if (result.rejected.length) { + throw new Error('Failed to send sign-in email.'); + } + + res.send({ + email: credential.email, + }); + } + } + + async sendSignInEmail( + { email, signUp }: { email: string; signUp: boolean }, + redirectUri: string + ) { + const token = await this.token.createToken(TokenType.SignIn, email); + + const magicLink = this.url.link('/api/auth/magic-link', { + token, + email, + redirect_uri: redirectUri, + }); + + const result = await this.auth.sendSignInEmail(email, magicLink, signUp); + + return result; + } + + @Get('/sign-out') + async signOut( + @Req() req: Request, + @Res() res: Response, + @Query('redirect_uri') redirectUri?: string + ) { + const session = await this.auth.signOut( + req.cookies[AuthService.sessionCookieName], + parseAuthUserSeqNum(req.headers[AuthService.authUserSeqHeaderName]) + ); + + if (session) { + res.cookie(AuthService.sessionCookieName, session.id, { + expires: session.expiresAt ?? void 0, // expiredAt is `string | null` + ...this.auth.cookieOptions, + }); + } else { + res.clearCookie(AuthService.sessionCookieName); + } + + if (redirectUri) { + return this.url.safeRedirect(res, redirectUri); + } else { + return res.send(null); + } + } + + @Public() + @Get('/magic-link') + async magicLinkSignIn( + @Req() req: Request, + @Res() res: Response, + @Query('token') token?: string, + @Query('email') email?: string, + @Query('redirect_uri') redirectUri = this.url.home + ) { + if (!token || !email) { + throw new BadRequestException('Invalid Sign-in mail Token'); + } + + email = decodeURIComponent(email); + validators.assertValidEmail(email); + + const valid = await this.token.verifyToken(TokenType.SignIn, token, { + credential: email, + }); + + if (!valid) { + throw new BadRequestException('Invalid Sign-in mail Token'); + } + + const user = await this.user.fulfillUser(email, { + emailVerifiedAt: new Date(), + registered: true, + }); + + await this.auth.setCookie(req, res, user); + + 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) { + return { + user, + }; + } + + @Public() + @Get('/sessions') + async currentSessionUsers(@Req() req: Request) { + const token = req.cookies[AuthService.sessionCookieName]; + if (!token) { + return { + users: [], + }; + } + + return { + users: await this.auth.getUserList(token), + }; + } + + @Public() + @Get('/challenge') + async challenge() { + // TODO: impl in following PR + return { + challenge: randomUUID(), + resource: randomUUID(), + }; + } +} diff --git a/packages/backend/server/src/core/auth/current-user.ts b/packages/backend/server/src/core/auth/current-user.ts new file mode 100644 index 0000000000..b6757314f1 --- /dev/null +++ b/packages/backend/server/src/core/auth/current-user.ts @@ -0,0 +1,55 @@ +import type { ExecutionContext } from '@nestjs/common'; +import { createParamDecorator } from '@nestjs/common'; +import { User } from '@prisma/client'; + +import { getRequestResponseFromContext } from '../../fundamentals'; + +function getUserFromContext(context: ExecutionContext) { + return getRequestResponseFromContext(context).req.user; +} + +/** + * Used to fetch current user from the request context. + * + * > The user may be undefined if authorization token or session cookie is not provided. + * + * @example + * + * ```typescript + * // Graphql Query + * \@Query(() => UserType) + * user(@CurrentUser() user: CurrentUser) { + * return user; + * } + * ``` + * + * ```typescript + * // HTTP Controller + * \@Get('/user') + * user(@CurrentUser() user: CurrentUser) { + * return user; + * } + * ``` + * + * ```typescript + * // for public apis + * \@Public() + * \@Get('/session') + * session(@currentUser() user?: CurrentUser) { + * return user + * } + * ``` + */ +// interface and variable don't conflict +// eslint-disable-next-line no-redeclare +export const CurrentUser = createParamDecorator( + (_: unknown, context: ExecutionContext) => { + return getUserFromContext(context); + } +); + +export interface CurrentUser + extends Pick { + hasPassword: boolean | null; + emailVerified: boolean; +} diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index 7983f3adef..d1ff3ab89d 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -1,67 +1,74 @@ -import type { CanActivate, ExecutionContext } from '@nestjs/common'; +import type { + CanActivate, + ExecutionContext, + OnModuleInit, +} from '@nestjs/common'; import { - createParamDecorator, - Inject, Injectable, SetMetadata, UnauthorizedException, UseGuards, } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { PrismaClient } from '@prisma/client'; -import type { NextAuthOptions } from 'next-auth'; -import { AuthHandler } from 'next-auth/core'; +import { ModuleRef, Reflector } from '@nestjs/core'; -import { getRequestResponseFromContext } from '../../fundamentals'; -import { NextAuthOptionsProvide } from './next-auth-options'; -import { AuthService } from './service'; +import { Config, getRequestResponseFromContext } from '../../fundamentals'; +import { AuthService, parseAuthUserSeqNum } from './service'; -export function getUserFromContext(context: ExecutionContext) { - return getRequestResponseFromContext(context).req.user; +function extractTokenFromHeader(authorization: string) { + if (!/^Bearer\s/i.test(authorization)) { + return; + } + + return authorization.substring(7); } -/** - * Used to fetch current user from the request context. - * - * > The user may be undefined if authorization token is not provided. - * - * @example - * - * ```typescript - * // Graphql Query - * \@Query(() => UserType) - * user(@CurrentUser() user?: User) { - * return user; - * } - * ``` - * - * ```typescript - * // HTTP Controller - * \@Get('/user) - * user(@CurrentUser() user?: User) { - * return user; - * } - * ``` - */ -export const CurrentUser = createParamDecorator( - (_: unknown, context: ExecutionContext) => { - return getUserFromContext(context); - } -); - @Injectable() -class AuthGuard implements CanActivate { +export class AuthGuard implements CanActivate, OnModuleInit { + private auth!: AuthService; + constructor( - @Inject(NextAuthOptionsProvide) - private readonly nextAuthOptions: NextAuthOptions, - private readonly auth: AuthService, - private readonly prisma: PrismaClient, + private readonly config: Config, + private readonly ref: ModuleRef, private readonly reflector: Reflector ) {} + onModuleInit() { + this.auth = this.ref.get(AuthService, { strict: false }); + } + async canActivate(context: ExecutionContext) { - const { req, res } = getRequestResponseFromContext(context); - const token = req.headers.authorization; + const { req } = getRequestResponseFromContext(context); + + // check cookie + let sessionToken: string | undefined = + req.cookies[AuthService.sessionCookieName]; + + // backward compatibility for client older then 0.12 + // TODO: remove + if (!sessionToken) { + sessionToken = + req.cookies[ + this.config.https + ? '__Secure-next-auth.session-token' + : 'next-auth.session-token' + ]; + } + + if (!sessionToken && req.headers.authorization) { + sessionToken = extractTokenFromHeader(req.headers.authorization); + } + + if (sessionToken) { + const userSeq = parseAuthUserSeqNum( + req.headers[AuthService.authUserSeqHeaderName] + ); + + const user = await this.auth.getUser(sessionToken, userSeq); + + if (user) { + req.user = user; + } + } // api is public const isPublic = this.reflector.get( @@ -69,63 +76,15 @@ class AuthGuard implements CanActivate { context.getHandler() ); - // FIXME(@forehalo): @Publicable() is duplicated with @CurrentUser() user?: User - // ^ optional - // we can prefetch user session in each request even before this `Guard` - // api can be public, but if user is logged in, we can get user info - const isPublicable = this.reflector.get( - 'isPublicable', - context.getHandler() - ); - if (isPublic) { return true; - } else if (!token) { - if (!req.cookies) { - return isPublicable; - } - - const session = await AuthHandler({ - req: { - cookies: req.cookies, - action: 'session', - method: 'GET', - headers: req.headers, - }, - options: this.nextAuthOptions, - }); - - const { body = {}, cookies, status = 200 } = session; - if (!body && !isPublicable) { - throw new UnauthorizedException('You are not signed in.'); - } - - // @ts-expect-error body is user here - req.user = body.user; - if (cookies && res) { - for (const cookie of cookies) { - res.cookie(cookie.name, cookie.value, cookie.options); - } - } - - return Boolean( - status === 200 && - typeof body !== 'string' && - // ignore body if api is publicable - (Object.keys(body).length || isPublicable) - ); - } else { - const [type, jwt] = token.split(' ') ?? []; - - if (type === 'Bearer') { - const claims = await this.auth.verify(jwt); - req.user = await this.prisma.user.findUnique({ - where: { id: claims.id }, - }); - return !!req.user; - } } - return false; + + if (!req.user) { + throw new UnauthorizedException('You are not signed in.'); + } + + return true; } } @@ -140,7 +99,7 @@ class AuthGuard implements CanActivate { * ```typescript * \@Auth() * \@Query(() => UserType) - * user(@CurrentUser() user: User) { + * user(@CurrentUser() user: CurrentUser) { * return user; * } * ``` @@ -151,5 +110,3 @@ export const Auth = () => { // api is public accessible export const Public = () => SetMetadata('isPublic', true); -// api is public accessible, but if user is logged in, we can get user info -export const Publicable = () => SetMetadata('isPublicable', true); diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index b5b72ab31e..b557ba65cc 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -1,18 +1,21 @@ -import { Global, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; -import { NextAuthController } from './next-auth.controller'; -import { NextAuthOptionsProvider } from './next-auth-options'; +import { FeatureModule } from '../features'; +import { UserModule } from '../user'; +import { AuthController } from './controller'; import { AuthResolver } from './resolver'; import { AuthService } from './service'; +import { TokenService } from './token'; -@Global() @Module({ - providers: [AuthService, AuthResolver, NextAuthOptionsProvider], - exports: [AuthService, NextAuthOptionsProvider], - controllers: [NextAuthController], + imports: [FeatureModule, UserModule], + providers: [AuthService, AuthResolver, TokenService], + exports: [AuthService], + controllers: [AuthController], }) export class AuthModule {} export * from './guard'; -export { TokenType } from './resolver'; +export { ClientTokenType } from './resolver'; export { AuthService }; +export * from './current-user'; diff --git a/packages/backend/server/src/core/auth/next-auth-options.ts b/packages/backend/server/src/core/auth/next-auth-options.ts deleted file mode 100644 index 72b368b025..0000000000 --- a/packages/backend/server/src/core/auth/next-auth-options.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { PrismaAdapter } from '@auth/prisma-adapter'; -import { FactoryProvider, Logger } from '@nestjs/common'; -import { verify } from '@node-rs/argon2'; -import { PrismaClient } from '@prisma/client'; -import { assign, omit } from 'lodash-es'; -import { NextAuthOptions } from 'next-auth'; -import Credentials from 'next-auth/providers/credentials'; -import Email from 'next-auth/providers/email'; -import Github from 'next-auth/providers/github'; -import Google from 'next-auth/providers/google'; - -import { Config, MailService, SessionService } from '../../fundamentals'; -import { FeatureType } from '../features'; -import { Quota_FreePlanV1_1 } from '../quota'; -import { - decode, - encode, - sendVerificationRequest, - SendVerificationRequestParams, -} from './utils'; - -export const NextAuthOptionsProvide = Symbol('NextAuthOptions'); - -const TrustedProviders = ['google']; - -export const NextAuthOptionsProvider: FactoryProvider = { - provide: NextAuthOptionsProvide, - useFactory( - config: Config, - prisma: PrismaClient, - mailer: MailService, - session: SessionService - ) { - const logger = new Logger('NextAuth'); - const prismaAdapter = PrismaAdapter(prisma); - // createUser exists in the adapter - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const createUser = prismaAdapter.createUser!.bind(prismaAdapter); - prismaAdapter.createUser = async data => { - const userData = { - name: data.name, - email: data.email, - avatarUrl: '', - emailVerified: data.emailVerified, - features: { - create: { - reason: 'created by email sign up', - activated: true, - feature: { - connect: { - feature_version: Quota_FreePlanV1_1, - }, - }, - }, - }, - }; - if (data.email && !data.name) { - userData.name = data.email.split('@')[0]; - } - if (data.image) { - userData.avatarUrl = data.image; - } - // @ts-expect-error third part library type mismatch - return createUser(userData); - }; - // linkAccount exists in the adapter - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const linkAccount = prismaAdapter.linkAccount!.bind(prismaAdapter); - prismaAdapter.linkAccount = async account => { - // google account must be a verified email - if (TrustedProviders.includes(account.provider)) { - await prisma.user.update({ - where: { - id: account.userId, - }, - data: { - emailVerified: new Date(), - }, - }); - } - return linkAccount(account) as Promise; - }; - // getUser exists in the adapter - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const getUser = prismaAdapter.getUser!.bind(prismaAdapter)!; - prismaAdapter.getUser = async id => { - const result = await getUser(id); - if (result) { - // @ts-expect-error Third part library type mismatch - result.image = result.avatarUrl; - // @ts-expect-error Third part library type mismatch - result.hasPassword = Boolean(result.password); - } - return result; - }; - - prismaAdapter.createVerificationToken = async data => { - await session.set( - `${data.identifier}:${data.token}`, - Date.now() + session.sessionTtl - ); - return data; - }; - - prismaAdapter.useVerificationToken = async ({ identifier, token }) => { - const expires = await session.get(`${identifier}:${token}`); - if (expires) { - return { identifier, token, expires: new Date(expires) }; - } else { - return null; - } - }; - - const nextAuthOptions: NextAuthOptions = { - providers: [], - // @ts-expect-error Third part library type mismatch - adapter: prismaAdapter, - debug: !config.node.prod, - logger: { - debug(code, metadata) { - logger.debug(`${code}: ${JSON.stringify(metadata)}`); - }, - error(code, metadata) { - if (metadata instanceof Error) { - // @ts-expect-error assign code to error - metadata.code = code; - logger.error(metadata); - } else if (metadata.error instanceof Error) { - assign(metadata.error, omit(metadata, 'error'), { code }); - logger.error(metadata.error); - } - }, - warn(code) { - logger.warn(code); - }, - }, - }; - - nextAuthOptions.providers.push( - // @ts-expect-error esm interop issue - Credentials.default({ - name: 'Password', - credentials: { - email: { - label: 'Email', - type: 'text', - placeholder: 'torvalds@osdl.org', - }, - password: { label: 'Password', type: 'password' }, - }, - async authorize( - credentials: - | Record<'email' | 'password' | 'hashedPassword', string> - | undefined - ) { - if (!credentials) { - return null; - } - const { password, hashedPassword } = credentials; - if (!password || !hashedPassword) { - return null; - } - if (!(await verify(hashedPassword, password))) { - return null; - } - return credentials; - }, - }) - ); - - if (config.mailer && mailer) { - nextAuthOptions.providers.push( - // @ts-expect-error esm interop issue - Email.default({ - sendVerificationRequest: (params: SendVerificationRequestParams) => - sendVerificationRequest(config, logger, mailer, session, params), - }) - ); - } - - if (config.auth.oauthProviders.github) { - nextAuthOptions.providers.push( - // @ts-expect-error esm interop issue - Github.default({ - clientId: config.auth.oauthProviders.github.clientId, - clientSecret: config.auth.oauthProviders.github.clientSecret, - allowDangerousEmailAccountLinking: true, - }) - ); - } - - if (config.auth.oauthProviders.google?.enabled) { - nextAuthOptions.providers.push( - // @ts-expect-error esm interop issue - Google.default({ - clientId: config.auth.oauthProviders.google.clientId, - clientSecret: config.auth.oauthProviders.google.clientSecret, - checks: 'nonce', - allowDangerousEmailAccountLinking: true, - authorization: { - params: { scope: 'openid email profile', prompt: 'select_account' }, - }, - }) - ); - } - - if (nextAuthOptions.providers.length > 1) { - // not only credentials provider - nextAuthOptions.session = { strategy: 'database' }; - } - - nextAuthOptions.jwt = { - encode: async ({ token, maxAge }) => - encode(config, prisma, token, maxAge), - decode: async ({ token }) => decode(config, token), - }; - nextAuthOptions.secret ??= config.auth.nextAuthSecret; - - nextAuthOptions.callbacks = { - session: async ({ session, user, token }) => { - if (session.user) { - if (user) { - // @ts-expect-error Third part library type mismatch - session.user.id = user.id; - // @ts-expect-error Third part library type mismatch - session.user.image = user.image ?? user.avatarUrl; - // @ts-expect-error Third part library type mismatch - session.user.emailVerified = user.emailVerified; - // @ts-expect-error Third part library type mismatch - session.user.hasPassword = Boolean(user.password); - } else { - // technically the sub should be the same as id - // @ts-expect-error Third part library type mismatch - session.user.id = token.sub; - // @ts-expect-error Third part library type mismatch - session.user.emailVerified = token.emailVerified; - // @ts-expect-error Third part library type mismatch - session.user.hasPassword = token.hasPassword; - } - if (token && token.picture) { - session.user.image = token.picture; - } - } - return session; - }, - signIn: async ({ profile, user }) => { - if (!config.featureFlags.earlyAccessPreview) { - return true; - } - const email = profile?.email ?? user.email; - if (email) { - // FIXME: cannot inject FeatureManagementService here - // it will cause prisma.account to be undefined - // then prismaAdapter.getUserByAccount will throw error - if (email.endsWith('@toeverything.info')) return true; - return prisma.userFeatures - .count({ - where: { - user: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - feature: { - feature: FeatureType.EarlyAccess, - }, - activated: true, - }, - }) - .then(count => count > 0); - } - return false; - }, - redirect({ url }) { - return url; - }, - }; - - nextAuthOptions.pages = { - newUser: '/auth/onboarding', - }; - return nextAuthOptions; - }, - inject: [Config, PrismaClient, MailService, SessionService], -}; diff --git a/packages/backend/server/src/core/auth/next-auth.controller.ts b/packages/backend/server/src/core/auth/next-auth.controller.ts deleted file mode 100644 index 22ef1dca5f..0000000000 --- a/packages/backend/server/src/core/auth/next-auth.controller.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { URLSearchParams } from 'node:url'; - -import { - All, - BadRequestException, - Controller, - Get, - Inject, - Logger, - Next, - NotFoundException, - Query, - Req, - Res, - UseGuards, -} from '@nestjs/common'; -import { hash, verify } from '@node-rs/argon2'; -import { PrismaClient, type User } from '@prisma/client'; -import type { NextFunction, Request, Response } from 'express'; -import { pick } from 'lodash-es'; -import { nanoid } from 'nanoid'; -import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth'; -import { AuthHandler } from 'next-auth/core'; - -import { - AuthThrottlerGuard, - Config, - metrics, - SessionService, - Throttle, -} from '../../fundamentals'; -import { NextAuthOptionsProvide } from './next-auth-options'; -import { AuthService } from './service'; - -const BASE_URL = '/api/auth/'; - -const DEFAULT_SESSION_EXPIRE_DATE = 2592000 * 1000; // 30 days - -@Controller(BASE_URL) -export class NextAuthController { - private readonly callbackSession; - - private readonly logger = new Logger('NextAuthController'); - - constructor( - readonly config: Config, - readonly prisma: PrismaClient, - private readonly authService: AuthService, - @Inject(NextAuthOptionsProvide) - private readonly nextAuthOptions: NextAuthOptions, - private readonly session: SessionService - ) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.callbackSession = nextAuthOptions.callbacks!.session; - } - - @UseGuards(AuthThrottlerGuard) - @Throttle({ - default: { - limit: 60, - ttl: 60, - }, - }) - @Get('/challenge') - async getChallenge(@Res() res: Response) { - const challenge = nanoid(); - const resource = nanoid(); - await this.session.set(challenge, resource, 5 * 60 * 1000); - res.json({ challenge, resource }); - } - - @UseGuards(AuthThrottlerGuard) - @Throttle({ - default: { - limit: 60, - ttl: 60, - }, - }) - @All('*') - async auth( - @Req() req: Request, - @Res() res: Response, - @Query() query: Record, - @Next() next: NextFunction - ) { - if (req.path === '/api/auth/signin' && req.method === 'GET') { - const query = req.query - ? // @ts-expect-error req.query is satisfy with the Record - `?${new URLSearchParams(req.query).toString()}` - : ''; - res.redirect(`/signin${query}`); - return; - } - const [action, providerId] = req.url // start with request url - .slice(BASE_URL.length) // make relative to baseUrl - .replace(/\?.*/, '') // remove query part, use only path part - .split('/') as [AuthAction, string]; // as array of strings; - - metrics.auth.counter('call_counter').add(1, { action, providerId }); - - const credentialsSignIn = - req.method === 'POST' && providerId === 'credentials'; - let userId: string | undefined; - if (credentialsSignIn) { - const { email } = req.body; - if (email) { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); - if (!user) { - req.statusCode = 401; - req.statusMessage = 'User not found'; - req.body = null; - throw new NotFoundException(`User not found`); - } else { - userId = user.id; - req.body = { - ...req.body, - name: user.name, - email: user.email, - image: user.avatarUrl, - hashedPassword: user.password, - }; - } - } - } - const options = this.nextAuthOptions; - if (req.method === 'POST' && action === 'session') { - if (typeof req.body !== 'object' || typeof req.body.data !== 'object') { - metrics.auth - .counter('call_fails_counter') - .add(1, { reason: 'invalid_session_data' }); - throw new BadRequestException(`Invalid new session data`); - } - const user = await this.updateSession(req, req.body.data); - // callbacks.session existed - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.callbacks!.session = ({ session }) => { - return { - user: { - ...pick(user, 'id', 'name', 'email'), - image: user.avatarUrl, - hasPassword: !!user.password, - }, - expires: session.expires, - }; - }; - } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.callbacks!.session = this.callbackSession; - } - - if ( - this.config.auth.captcha.enable && - req.method === 'POST' && - action === 'signin' && - // TODO: add credentials support in frontend - ['email'].includes(providerId) - ) { - const isVerified = await this.verifyChallenge(req, res); - if (!isVerified) return; - } - - const { status, headers, body, redirect, cookies } = await AuthHandler({ - req: { - body: req.body, - query: query, - method: req.method, - action, - providerId, - error: query.error ?? providerId, - cookies: req.cookies, - }, - options, - }); - - if (headers) { - for (const { key, value } of headers) { - res.setHeader(key, value); - } - } - if (cookies) { - for (const cookie of cookies) { - res.cookie(cookie.name, cookie.value, cookie.options); - } - } - - let nextAuthTokenCookie: (CookieOption & { value: string }) | undefined; - const secureCookiePrefix = '__Secure-'; - const sessionCookieName = `next-auth.session-token`; - // next-auth credentials login only support JWT strategy - // https://next-auth.js.org/configuration/providers/credentials - // let's store the session token in the database - if ( - credentialsSignIn && - (nextAuthTokenCookie = cookies?.find( - ({ name }) => - name === sessionCookieName || - name === `${secureCookiePrefix}${sessionCookieName}` - )) - ) { - const cookieExpires = new Date(); - cookieExpires.setTime( - cookieExpires.getTime() + DEFAULT_SESSION_EXPIRE_DATE - ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await this.nextAuthOptions.adapter!.createSession!({ - sessionToken: nextAuthTokenCookie.value, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - userId: userId!, - expires: cookieExpires, - }); - } - - if (redirect?.endsWith('api/auth/error?error=AccessDenied')) { - this.logger.log(`Early access redirect headers: ${req.headers}`); - metrics.auth - .counter('call_fails_counter') - .add(1, { reason: 'no_early_access_permission' }); - - if ( - !req.headers?.referer || - checkUrlOrigin(req.headers.referer, 'https://accounts.google.com') - ) { - res.redirect('https://community.affine.pro/c/insider-general/'); - } else { - res.status(403); - res.json({ - url: 'https://community.affine.pro/c/insider-general/', - error: `You don't have early access permission`, - }); - } - return; - } - - if (status) { - res.status(status); - } - - if (redirect) { - if (providerId === 'credentials') { - res.send(JSON.stringify({ ok: true, url: redirect })); - } else if ( - action === 'callback' || - action === 'error' || - (providerId !== 'credentials' && - // login in the next-auth page, /api/auth/signin, auto redirect. - // otherwise, return the json value to allow frontend to handle the redirect. - req.headers?.referer?.includes?.('/api/auth/signin')) - ) { - res.redirect(redirect); - } else { - res.json({ url: redirect }); - } - } else if (typeof body === 'string') { - res.send(body); - } else if (body && typeof body === 'object') { - res.json(body); - } else { - next(); - } - } - - private async updateSession( - req: Request, - newSession: Partial> & { oldPassword?: string } - ): Promise { - const { name, email, password, oldPassword } = newSession; - if (!name && !email && !password) { - throw new BadRequestException(`Invalid new session data`); - } - if (password) { - const user = await this.verifyUserFromRequest(req); - const { password: userPassword } = user; - if (!oldPassword) { - if (userPassword) { - throw new BadRequestException( - `Old password is required to update password` - ); - } - } else { - if (!userPassword) { - throw new BadRequestException(`No existed password`); - } - if (await verify(userPassword, oldPassword)) { - await this.prisma.user.update({ - where: { - id: user.id, - }, - data: { - ...pick(newSession, 'email', 'name'), - password: await hash(password), - }, - }); - } - } - return user; - } else { - const user = await this.verifyUserFromRequest(req); - return this.prisma.user.update({ - where: { - id: user.id, - }, - data: pick(newSession, 'name', 'email'), - }); - } - } - - private async verifyChallenge(req: Request, res: Response): Promise { - const challenge = req.query?.challenge; - if (typeof challenge === 'string' && challenge) { - const resource = await this.session.get(challenge); - - if (!resource) { - this.rejectResponse(res, 'Invalid Challenge'); - return false; - } - - const isChallengeVerified = - await this.authService.verifyChallengeResponse( - req.query?.token, - resource - ); - - this.logger.debug( - `Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}` - ); - - if (!isChallengeVerified) { - this.rejectResponse(res, 'Invalid Challenge Response'); - return false; - } - } else { - const isTokenVerified = await this.authService.verifyCaptchaToken( - req.query?.token, - req.headers['CF-Connecting-IP'] as string - ); - - if (!isTokenVerified) { - this.rejectResponse(res, 'Invalid Captcha Response'); - return false; - } - } - return true; - } - - private async verifyUserFromRequest(req: Request): Promise { - const token = req.headers.authorization; - if (!token) { - const session = await AuthHandler({ - req: { - cookies: req.cookies, - action: 'session', - method: 'GET', - headers: req.headers, - }, - options: this.nextAuthOptions, - }); - - const { body } = session; - // @ts-expect-error check if body.user exists - if (body && body.user && body.user.id) { - const user = await this.prisma.user.findUnique({ - where: { - // @ts-expect-error body.user.id exists - id: body.user.id, - }, - }); - if (user) { - return user; - } - } - } else { - const [type, jwt] = token.split(' ') ?? []; - - if (type === 'Bearer') { - const claims = await this.authService.verify(jwt); - const user = await this.prisma.user.findUnique({ - where: { id: claims.id }, - }); - if (user) { - return user; - } - } - } - throw new BadRequestException(`User not found`); - } - - rejectResponse(res: Response, error: string, status = 400) { - res.status(status); - res.json({ - url: `${this.config.baseUrl}/api/auth/error?${new URLSearchParams({ - error, - }).toString()}`, - error, - }); - } -} - -const checkUrlOrigin = (url: string, origin: string) => { - try { - return new URL(url).origin === origin; - } catch (e) { - return false; - } -}; diff --git a/packages/backend/server/src/core/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts index 8ab9ef089d..edd2d2168d 100644 --- a/packages/backend/server/src/core/auth/resolver.ts +++ b/packages/backend/server/src/core/auth/resolver.ts @@ -10,24 +10,22 @@ import { Mutation, ObjectType, Parent, + Query, ResolveField, Resolver, } from '@nestjs/graphql'; -import type { Request } from 'express'; -import { nanoid } from 'nanoid'; +import type { Request, Response } from 'express'; -import { - CloudThrottlerGuard, - Config, - SessionService, - Throttle, -} from '../../fundamentals'; -import { UserType } from '../users'; -import { Auth, CurrentUser } from './guard'; +import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals'; +import { UserType } from '../user/types'; +import { validators } from '../utils/validators'; +import { CurrentUser } from './current-user'; +import { Public } from './guard'; import { AuthService } from './service'; +import { TokenService, TokenType } from './token'; -@ObjectType() -export class TokenType { +@ObjectType('tokenType') +export class ClientTokenType { @Field() token!: string; @@ -50,46 +48,57 @@ export class AuthResolver { constructor( private readonly config: Config, private readonly auth: AuthService, - private readonly session: SessionService + private readonly token: TokenService ) {} + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Public() + @Query(() => UserType, { + name: 'currentUser', + description: 'Get current user', + nullable: true, + }) + currentUser(@CurrentUser() user?: CurrentUser): UserType | undefined { + return user; + } + @Throttle({ default: { limit: 20, ttl: 60, }, }) - @ResolveField(() => TokenType) - async token( - @Context() ctx: { req: Request }, - @CurrentUser() currentUser: UserType, + @ResolveField(() => ClientTokenType, { + name: 'token', + deprecationReason: 'use [/api/auth/authorize]', + }) + async clientToken( + @CurrentUser() currentUser: CurrentUser, @Parent() user: UserType - ) { + ): Promise { if (user.id !== currentUser.id) { - throw new BadRequestException('Invalid user'); + throw new ForbiddenException('Invalid user'); } - let sessionToken: string | undefined; - - // only return session if the request is from the same origin & path == /open-app - if ( - ctx.req.headers.referer && - ctx.req.headers.host && - new URL(ctx.req.headers.referer).pathname.startsWith('/open-app') && - ctx.req.headers.host === new URL(this.config.origin).host - ) { - const cookiePrefix = this.config.node.prod ? '__Secure-' : ''; - const sessionCookieName = `${cookiePrefix}next-auth.session-token`; - sessionToken = ctx.req.cookies?.[sessionCookieName]; - } + const session = await this.auth.createUserSession( + user, + undefined, + this.config.auth.accessToken.ttl + ); return { - sessionToken, - token: this.auth.sign(user), - refresh: this.auth.refresh(user), + sessionToken: session.sessionId, + token: session.sessionId, + refresh: '', }; } + @Public() @Throttle({ default: { limit: 10, @@ -98,16 +107,19 @@ export class AuthResolver { }) @Mutation(() => UserType) async signUp( - @Context() ctx: { req: Request }, + @Context() ctx: { req: Request; res: Response }, @Args('name') name: string, @Args('email') email: string, @Args('password') password: string ) { + validators.assertValidCredential({ email, password }); const user = await this.auth.signUp(name, email, password); + await this.auth.setCookie(ctx.req, ctx.res, user); ctx.req.user = user; return user; } + @Public() @Throttle({ default: { limit: 10, @@ -116,11 +128,13 @@ export class AuthResolver { }) @Mutation(() => UserType) async signIn( - @Context() ctx: { req: Request }, + @Context() ctx: { req: Request; res: Response }, @Args('email') email: string, @Args('password') password: string ) { + validators.assertValidCredential({ email, password }); const user = await this.auth.signIn(email, password); + await this.auth.setCookie(ctx.req, ctx.res, user); ctx.req.user = user; return user; } @@ -132,28 +146,26 @@ export class AuthResolver { }, }) @Mutation(() => UserType) - @Auth() async changePassword( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('token') token: string, @Args('newPassword') newPassword: string ) { - const id = await this.session.get(token); - if (!user.emailVerified) { - throw new ForbiddenException('Please verify the email first'); - } - if ( - !id || - (id !== user.id && - // change password after sign in with email link - // we only create user account after user sign in with email link - id !== user.email) - ) { + validators.assertValidPassword(newPassword); + // NOTE: Set & Change password are using the same token type. + const valid = await this.token.verifyToken( + TokenType.ChangePassword, + token, + { + credential: user.id, + } + ); + + if (!valid) { throw new ForbiddenException('Invalid token'); } await this.auth.changePassword(user.email, newPassword); - await this.session.delete(token); return user; } @@ -165,25 +177,24 @@ export class AuthResolver { }, }) @Mutation(() => UserType) - @Auth() async changeEmail( - @CurrentUser() user: UserType, - @Args('token') token: string + @CurrentUser() user: CurrentUser, + @Args('token') token: string, + @Args('email') email: string ) { - const key = await this.session.get(token); - if (!key) { + validators.assertValidEmail(email); + // @see [sendChangeEmail] + const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { + credential: user.id, + }); + + if (!valid) { throw new ForbiddenException('Invalid token'); } - // email has set token in `sendVerifyChangeEmail` - const [id, email] = key.split(','); - if (!id || id !== user.id || !email) { - throw new ForbiddenException('Invalid token'); - } - - await this.auth.changeEmail(id, email); - await this.session.delete(token); + email = decodeURIComponent(email); + await this.auth.changeEmail(user.id, email); await this.auth.sendNotificationChangeEmail(email); return user; @@ -196,19 +207,29 @@ export class AuthResolver { }, }) @Mutation(() => Boolean) - @Auth() async sendChangePasswordEmail( - @CurrentUser() user: UserType, - @Args('email') email: string, - @Args('callbackUrl') callbackUrl: string + @CurrentUser() user: CurrentUser, + @Args('callbackUrl') callbackUrl: string, + // @deprecated + @Args('email', { nullable: true }) _email?: string ) { - const token = nanoid(); - await this.session.set(token, user.id); + if (!user.emailVerified) { + throw new ForbiddenException('Please verify your email first.'); + } + + const token = await this.token.createToken( + TokenType.ChangePassword, + user.id + ); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); - const res = await this.auth.sendChangePasswordEmail(email, url.toString()); + const res = await this.auth.sendChangePasswordEmail( + user.email, + url.toString() + ); + return !res.rejected.length; } @@ -219,19 +240,27 @@ export class AuthResolver { }, }) @Mutation(() => Boolean) - @Auth() async sendSetPasswordEmail( - @CurrentUser() user: UserType, - @Args('email') email: string, - @Args('callbackUrl') callbackUrl: string + @CurrentUser() user: CurrentUser, + @Args('callbackUrl') callbackUrl: string, + @Args('email', { nullable: true }) _email?: string ) { - const token = nanoid(); - await this.session.set(token, user.id); + if (!user.emailVerified) { + throw new ForbiddenException('Please verify your email first.'); + } + + const token = await this.token.createToken( + TokenType.ChangePassword, + user.id + ); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); - const res = await this.auth.sendSetPasswordEmail(email, url.toString()); + const res = await this.auth.sendSetPasswordEmail( + user.email, + url.toString() + ); return !res.rejected.length; } @@ -249,19 +278,22 @@ export class AuthResolver { }, }) @Mutation(() => Boolean) - @Auth() async sendChangeEmail( - @CurrentUser() user: UserType, - @Args('email') email: string, - @Args('callbackUrl') callbackUrl: string + @CurrentUser() user: CurrentUser, + @Args('callbackUrl') callbackUrl: string, + // @deprecated + @Args('email', { nullable: true }) _email?: string ) { - const token = nanoid(); - await this.session.set(token, user.id); + if (!user.emailVerified) { + throw new ForbiddenException('Please verify your email first.'); + } + + const token = await this.token.createToken(TokenType.ChangeEmail, user.id); const url = new URL(callbackUrl, this.config.baseUrl); url.searchParams.set('token', token); - const res = await this.auth.sendChangeEmail(email, url.toString()); + const res = await this.auth.sendChangeEmail(user.email, url.toString()); return !res.rejected.length; } @@ -272,34 +304,92 @@ export class AuthResolver { }, }) @Mutation(() => Boolean) - @Auth() async sendVerifyChangeEmail( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('token') token: string, @Args('email') email: string, @Args('callbackUrl') callbackUrl: string ) { - const id = await this.session.get(token); - if (!id || id !== user.id) { + validators.assertValidEmail(email); + const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, { + credential: user.id, + }); + + if (!valid) { throw new ForbiddenException('Invalid token'); } const hasRegistered = await this.auth.getUserByEmail(email); if (hasRegistered) { - throw new BadRequestException(`Invalid user email`); + if (hasRegistered.id !== user.id) { + throw new BadRequestException(`The email provided has been taken.`); + } else { + throw new BadRequestException( + `The email provided is the same as the current email.` + ); + } } - const withEmailToken = nanoid(); - await this.session.set(withEmailToken, `${user.id},${email}`); + const verifyEmailToken = await this.token.createToken( + TokenType.VerifyEmail, + user.id + ); const url = new URL(callbackUrl, this.config.baseUrl); - url.searchParams.set('token', withEmailToken); + url.searchParams.set('token', verifyEmailToken); + url.searchParams.set('email', email); const res = await this.auth.sendVerifyChangeEmail(email, url.toString()); - await this.session.delete(token); - return !res.rejected.length; } + + @Throttle({ + default: { + limit: 5, + ttl: 60, + }, + }) + @Mutation(() => Boolean) + async sendVerifyEmail( + @CurrentUser() user: CurrentUser, + @Args('callbackUrl') callbackUrl: string + ) { + const token = await this.token.createToken(TokenType.VerifyEmail, user.id); + + const url = new URL(callbackUrl, this.config.baseUrl); + url.searchParams.set('token', token); + + const res = await this.auth.sendVerifyEmail(user.email, url.toString()); + return !res.rejected.length; + } + + @Throttle({ + default: { + limit: 5, + ttl: 60, + }, + }) + @Mutation(() => Boolean) + async verifyEmail( + @CurrentUser() user: CurrentUser, + @Args('token') token: string + ) { + if (!token) { + throw new BadRequestException('Invalid token'); + } + + const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, { + credential: user.id, + }); + + if (!valid) { + throw new ForbiddenException('Invalid token'); + } + + const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id); + + return emailVerifiedAt !== null; + } } diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 41535175e2..187f3b28cd 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -1,299 +1,333 @@ -import { randomUUID } from 'node:crypto'; - import { BadRequestException, Injectable, - InternalServerErrorException, - UnauthorizedException, + NotAcceptableException, + NotFoundException, + OnApplicationBootstrap, } from '@nestjs/common'; -import { hash, verify } from '@node-rs/argon2'; -import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken'; import { PrismaClient, type User } from '@prisma/client'; -import { nanoid } from 'nanoid'; +import type { CookieOptions, Request, Response } from 'express'; +import { assign, omit } from 'lodash-es'; import { Config, + CryptoHelper, MailService, - verifyChallengeResponse, + SessionCache, } from '../../fundamentals'; -import { Quota_FreePlanV1_1 } from '../quota'; +import { FeatureManagementService } from '../features/management'; +import { UserService } from '../user/service'; +import type { CurrentUser } from './current-user'; -export type UserClaim = Pick< - User, - 'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl' -> & { - hasPassword?: boolean; -}; +export function parseAuthUserSeqNum(value: any) { + switch (typeof value) { + case 'number': { + return value; + } + case 'string': { + value = Number.parseInt(value); + return Number.isNaN(value) ? 0 : value; + } -export const getUtcTimestamp = () => Math.floor(Date.now() / 1000); + default: { + return 0; + } + } +} + +export function sessionUser( + user: Pick< + User, + 'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt' + > & { password?: string | null } +): CurrentUser { + return assign( + omit(user, 'password', 'registered', 'emailVerifiedAt', 'createdAt'), + { + hasPassword: user.password !== null, + emailVerified: user.emailVerifiedAt !== null, + } + ); +} @Injectable() -export class AuthService { +export class AuthService implements OnApplicationBootstrap { + readonly cookieOptions: CookieOptions = { + sameSite: 'lax', + httpOnly: true, + path: '/', + domain: this.config.host, + secure: this.config.https, + }; + static readonly sessionCookieName = 'sid'; + static readonly authUserSeqHeaderName = 'x-auth-user'; + constructor( private readonly config: Config, - private readonly prisma: PrismaClient, - private readonly mailer: MailService + private readonly db: PrismaClient, + private readonly mailer: MailService, + private readonly feature: FeatureManagementService, + private readonly user: UserService, + private readonly crypto: CryptoHelper, + private readonly cache: SessionCache ) {} - sign(user: UserClaim) { - const now = getUtcTimestamp(); - return sign( - { - data: { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified?.toISOString(), - image: user.avatarUrl, - hasPassword: Boolean(user.hasPassword), - createdAt: user.createdAt.toISOString(), - }, - iat: now, - exp: now + this.config.auth.accessTokenExpiresIn, - iss: this.config.serverId, - sub: user.id, - aud: 'https://affine.pro', - jti: randomUUID({ - disableEntropyCache: true, - }), - }, - this.config.auth.privateKey, - { - algorithm: Algorithm.ES256, - } - ); - } - - refresh(user: UserClaim) { - const now = getUtcTimestamp(); - return sign( - { - data: { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified?.toISOString(), - image: user.avatarUrl, - hasPassword: Boolean(user.hasPassword), - createdAt: user.createdAt.toISOString(), - }, - exp: now + this.config.auth.refreshTokenExpiresIn, - iat: now, - iss: this.config.serverId, - sub: user.id, - aud: 'https://affine.pro', - jti: randomUUID({ - disableEntropyCache: true, - }), - }, - this.config.auth.privateKey, - { - algorithm: Algorithm.ES256, - } - ); - } - - async verify(token: string) { - try { - const data = ( - await jwtVerify(token, this.config.auth.publicKey, { - algorithms: [Algorithm.ES256], - iss: [this.config.serverId], - leeway: this.config.auth.leeway, - requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'], - aud: ['https://affine.pro'], - }) - ).data as UserClaim; - - return { - ...data, - emailVerified: data.emailVerified ? new Date(data.emailVerified) : null, - createdAt: new Date(data.createdAt), - }; - } catch (e) { - throw new UnauthorizedException('Invalid token'); + async onApplicationBootstrap() { + if (this.config.node.dev) { + await this.signUp('Dev User', 'dev@affine.pro', 'dev').catch(() => { + // ignore + }); } } - async verifyCaptchaToken(token: any, ip: string) { - if (typeof token !== 'string' || !token) return false; - - const formData = new FormData(); - formData.append('secret', this.config.auth.captcha.turnstile.secret); - formData.append('response', token); - formData.append('remoteip', ip); - // prevent replay attack - formData.append('idempotency_key', nanoid()); - - const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; - const result = await fetch(url, { - body: formData, - method: 'POST', - }); - const outcome = await result.json(); - - return ( - !!outcome.success && - // skip hostname check in dev mode - (this.config.node.dev || outcome.hostname === this.config.host) - ); + canSignIn(email: string) { + return this.feature.canEarlyAccess(email); } - async verifyChallengeResponse(response: any, resource: string) { - return verifyChallengeResponse( - response, - this.config.auth.captcha.challenge.bits, - resource - ); + async signUp( + name: string, + email: string, + password: string + ): Promise { + const user = await this.getUserByEmail(email); + + if (user) { + throw new BadRequestException('Email was taken'); + } + + const hashedPassword = await this.crypto.encryptPassword(password); + + return this.user + .createUser({ + name, + email, + password: hashedPassword, + }) + .then(sessionUser); } - async signIn(email: string, password: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); + async signIn(email: string, password: string) { + const user = await this.user.findUserWithHashedPasswordByEmail(email); if (!user) { - throw new BadRequestException('Invalid email'); + throw new NotFoundException('User Not Found'); } if (!user.password) { - throw new BadRequestException('User has no password'); + throw new NotAcceptableException( + 'User Password is not set. Should login throw email link.' + ); } - let equal = false; - try { - equal = await verify(user.password, password); - } catch (e) { - console.error(e); - throw new InternalServerErrorException(e, 'Verify password failed'); + + const passwordMatches = await this.crypto.verifyPassword( + password, + user.password + ); + + if (!passwordMatches) { + throw new NotAcceptableException('Incorrect Password'); } - if (!equal) { - throw new UnauthorizedException('Invalid password'); + + 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 signUp(name: string, email: string, password: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); + async getUser(token: string, seq = 0): Promise { + const session = await this.getSession(token); - if (user) { - throw new BadRequestException('Email already exists'); + // no such session + if (!session) { + return null; } - const hashedPassword = await hash(password); + const userSession = session.userSessions.at(seq); - return this.prisma.user.create({ - data: { - name, - email, - password: hashedPassword, - // TODO(@forehalo): handle in event system - features: { - create: { - reason: 'created by api sign up', - activated: true, - feature: { - connect: { - feature_version: Quota_FreePlanV1_1, - }, - }, - }, - }, - }, - }); - } - - async createAnonymousUser(email: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); - - if (user) { - throw new BadRequestException('Email already exists'); + // no such user session + if (!userSession) { + return null; } - return this.prisma.user.create({ - data: { - name: 'Unnamed', - email, - features: { - create: { - reason: 'created by invite sign up', - activated: true, - feature: { - connect: { - feature_version: Quota_FreePlanV1_1, - }, - }, - }, - }, - }, - }); - } + // user session expired + if (userSession.expiresAt && userSession.expiresAt <= new Date()) { + return null; + } - async getUserByEmail(email: string): Promise { - return this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, + const user = await this.db.user.findUnique({ + where: { id: userSession.userId }, }); - } - async isUserHasPassword(email: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); if (!user) { - throw new BadRequestException('Invalid email'); + return null; } - return Boolean(user.password); + + return sessionUser(user); + } + + async getUserList(token: string) { + const session = await this.getSession(token); + + if (!session || !session.userSessions.length) { + return []; + } + + const users = await this.db.user.findMany({ + where: { + id: { + in: session.userSessions.map(({ userId }) => userId), + }, + }, + }); + + // TODO(@forehalo): need to separate expired session, same for [getUser] + // Session + // | { user: LimitedUser { email, avatarUrl }, expired: true } + // | { user: User, expired: false } + return users.map(sessionUser); + } + + async signOut(token: string, seq = 0) { + const session = await this.getSession(token); + + if (session) { + // overflow the logged in user + if (session.userSessions.length <= seq) { + return session; + } + + await this.db.userSession.deleteMany({ + where: { id: session.userSessions[seq].id }, + }); + + // no more user session active, delete the whole session + if (session.userSessions.length === 1) { + await this.db.session.delete({ where: { id: session.id } }); + return null; + } + + return session; + } + + return null; + } + + async getSession(token: string) { + return this.db.$transaction(async tx => { + const session = await tx.session.findUnique({ + where: { + id: token, + }, + include: { + userSessions: { + orderBy: { + createdAt: 'asc', + }, + }, + }, + }); + + if (!session) { + return null; + } + + if (session.expiresAt && session.expiresAt <= new Date()) { + await tx.session.delete({ + where: { + id: session.id, + }, + }); + + return null; + } + + return session; + }); + } + + async createUserSession( + user: { id: string }, + existingSession?: string, + ttl = this.config.auth.session.ttl + ) { + const session = existingSession + ? await this.getSession(existingSession) + : null; + + const expiresAt = new Date(Date.now() + ttl * 1000); + if (session) { + return this.db.userSession.upsert({ + where: { + sessionId_userId: { + sessionId: session.id, + userId: user.id, + }, + }, + update: { + expiresAt, + }, + create: { + sessionId: session.id, + userId: user.id, + expiresAt, + }, + }); + } else { + return this.db.userSession.create({ + data: { + expiresAt, + session: { + create: {}, + }, + user: { + connect: { + id: user.id, + }, + }, + }, + }); + } + } + + async setCookie(req: Request, res: Response, user: { id: string }) { + const session = await this.createUserSession( + user, + req.cookies[AuthService.sessionCookieName] + ); + + res.cookie(AuthService.sessionCookieName, session.sessionId, { + expires: session.expiresAt ?? void 0, + ...this.cookieOptions, + }); + } + + async getUserByEmail(email: string) { + return this.user.findUserByEmail(email); } async changePassword(email: string, newPassword: string): Promise { - const user = await this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - emailVerified: { - not: null, - }, - }, - }); + const user = await this.getUserByEmail(email); if (!user) { throw new BadRequestException('Invalid email'); } - const hashedPassword = await hash(newPassword); + const hashedPassword = await this.crypto.encryptPassword(newPassword); - return this.prisma.user.update({ + return this.db.user.update({ where: { id: user.id, }, @@ -304,7 +338,7 @@ export class AuthService { } async changeEmail(id: string, newEmail: string): Promise { - const user = await this.prisma.user.findUnique({ + const user = await this.db.user.findUnique({ where: { id, }, @@ -314,12 +348,27 @@ export class AuthService { throw new BadRequestException('Invalid email'); } - return this.prisma.user.update({ + return this.db.user.update({ where: { id, }, data: { email: newEmail, + emailVerifiedAt: new Date(), + }, + }); + } + + async setEmailVerified(id: string) { + return await this.db.user.update({ + where: { + id, + }, + data: { + emailVerifiedAt: new Date(), + }, + select: { + emailVerifiedAt: true, }, }); } @@ -336,7 +385,20 @@ export class AuthService { async sendVerifyChangeEmail(email: string, callbackUrl: string) { return this.mailer.sendVerifyChangeEmail(email, callbackUrl); } + async sendVerifyEmail(email: string, callbackUrl: string) { + return this.mailer.sendVerifyEmail(email, callbackUrl); + } async sendNotificationChangeEmail(email: string) { return this.mailer.sendNotificationChangeEmail(email); } + + async sendSignInEmail(email: string, link: string, signUp: boolean) { + return signUp + ? await this.mailer.sendSignUpMail(link.toString(), { + to: email, + }) + : await this.mailer.sendSignInMail(link.toString(), { + to: email, + }); + } } diff --git a/packages/backend/server/src/core/auth/token.ts b/packages/backend/server/src/core/auth/token.ts new file mode 100644 index 0000000000..b154b7cbd6 --- /dev/null +++ b/packages/backend/server/src/core/auth/token.ts @@ -0,0 +1,84 @@ +import { randomUUID } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +import { CryptoHelper } from '../../fundamentals/helpers'; + +export enum TokenType { + SignIn, + VerifyEmail, + ChangeEmail, + ChangePassword, + Challenge, +} + +@Injectable() +export class TokenService { + constructor( + private readonly db: PrismaClient, + private readonly crypto: CryptoHelper + ) {} + + async createToken( + type: TokenType, + credential?: string, + ttlInSec: number = 30 * 60 + ) { + const plaintextToken = randomUUID(); + + const { token } = await this.db.verificationToken.create({ + data: { + type, + token: plaintextToken, + credential, + expiresAt: new Date(Date.now() + ttlInSec * 1000), + }, + }); + + return this.crypto.encrypt(token); + } + + async verifyToken( + type: TokenType, + token: string, + { + credential, + keep, + }: { + credential?: string; + keep?: boolean; + } = {} + ) { + token = this.crypto.decrypt(token); + const record = await this.db.verificationToken.findUnique({ + where: { + type_token: { + token, + type, + }, + }, + }); + + if (!record) { + return null; + } + + const expired = record.expiresAt <= new Date(); + const valid = + !expired && (!record.credential || record.credential === credential); + + if ((expired || valid) && !keep) { + await this.db.verificationToken.delete({ + where: { + type_token: { + token, + type, + }, + }, + }); + } + + return valid ? record : null; + } +} diff --git a/packages/backend/server/src/core/auth/utils/index.ts b/packages/backend/server/src/core/auth/utils/index.ts deleted file mode 100644 index c00310ed8f..0000000000 --- a/packages/backend/server/src/core/auth/utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { jwtDecode as decode, jwtEncode as encode } from './jwt'; -export { sendVerificationRequest } from './send-mail'; -export type { SendVerificationRequestParams } from 'next-auth/providers/email'; diff --git a/packages/backend/server/src/core/auth/utils/jwt.ts b/packages/backend/server/src/core/auth/utils/jwt.ts deleted file mode 100644 index 92eac1f55c..0000000000 --- a/packages/backend/server/src/core/auth/utils/jwt.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { BadRequestException } from '@nestjs/common'; -import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken'; -import { PrismaClient } from '@prisma/client'; -import { JWT } from 'next-auth/jwt'; - -import { Config } from '../../../fundamentals'; -import { getUtcTimestamp, UserClaim } from '../service'; - -export const jwtEncode = async ( - config: Config, - prisma: PrismaClient, - token: JWT | undefined, - maxAge: number | undefined -) => { - if (!token?.email) { - throw new BadRequestException('Missing email in jwt token'); - } - const user = await prisma.user.findFirstOrThrow({ - where: { - email: token.email, - }, - }); - const now = getUtcTimestamp(); - return sign( - { - data: { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified?.toISOString(), - picture: user.avatarUrl, - createdAt: user.createdAt.toISOString(), - hasPassword: Boolean(user.password), - }, - iat: now, - exp: now + (maxAge ?? config.auth.accessTokenExpiresIn), - iss: config.serverId, - sub: user.id, - aud: 'https://affine.pro', - jti: randomUUID({ - disableEntropyCache: true, - }), - }, - config.auth.privateKey, - { - algorithm: Algorithm.ES256, - } - ); -}; - -export const jwtDecode = async (config: Config, token: string | undefined) => { - if (!token) { - return null; - } - const { name, email, emailVerified, id, picture, hasPassword } = ( - await jwtVerify(token, config.auth.publicKey, { - algorithms: [Algorithm.ES256], - iss: [config.serverId], - leeway: config.auth.leeway, - requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'], - }) - ).data as Omit & { - picture: string | undefined; - }; - return { - name, - email, - emailVerified, - picture, - sub: id, - id, - hasPassword, - }; -}; diff --git a/packages/backend/server/src/core/auth/utils/send-mail.ts b/packages/backend/server/src/core/auth/utils/send-mail.ts deleted file mode 100644 index fdc463d90c..0000000000 --- a/packages/backend/server/src/core/auth/utils/send-mail.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Logger } from '@nestjs/common'; -import { nanoid } from 'nanoid'; -import type { SendVerificationRequestParams } from 'next-auth/providers/email'; - -import { Config, MailService, SessionService } from '../../../fundamentals'; - -export async function sendVerificationRequest( - config: Config, - logger: Logger, - mailer: MailService, - session: SessionService, - params: SendVerificationRequestParams -) { - const { identifier, url } = params; - const urlWithToken = new URL(url); - const callbackUrl = urlWithToken.searchParams.get('callbackUrl') || ''; - if (!callbackUrl) { - throw new Error('callbackUrl is not set'); - } else { - const newCallbackUrl = new URL(callbackUrl, config.origin); - - const token = nanoid(); - await session.set(token, identifier); - newCallbackUrl.searchParams.set('token', token); - - urlWithToken.searchParams.set('callbackUrl', newCallbackUrl.toString()); - } - - const result = await mailer.sendSignInEmail(urlWithToken.toString(), { - to: identifier, - }); - logger.log(`send verification email success: ${result.accepted.join(', ')}`); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length) { - throw new Error(`Email (${failed.join(', ')}) could not be sent`); - } -} diff --git a/packages/backend/server/src/core/config.ts b/packages/backend/server/src/core/config.ts index 6940a77aca..828027df56 100644 --- a/packages/backend/server/src/core/config.ts +++ b/packages/backend/server/src/core/config.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql'; import { DeploymentType } from '../fundamentals'; +import { Public } from './auth'; export enum ServerFeature { Payment = 'payment', + OAuth = 'oauth', } registerEnumType(ServerFeature, { @@ -15,9 +17,9 @@ registerEnumType(DeploymentType, { name: 'ServerDeploymentType', }); -const ENABLED_FEATURES: ServerFeature[] = []; +const ENABLED_FEATURES: Set = new Set(); export function ADD_ENABLED_FEATURES(feature: ServerFeature) { - ENABLED_FEATURES.push(feature); + ENABLED_FEATURES.add(feature); } @ObjectType() @@ -48,6 +50,7 @@ export class ServerConfigType { } export class ServerConfigResolver { + @Public() @Query(() => ServerConfigType, { description: 'server config', }) @@ -61,7 +64,7 @@ export class ServerConfigResolver { // the old flavors contains `selfhosted` but it actually not flavor but deployment type // this field should be removed after frontend feature flags implemented flavor: AFFiNE.type, - features: ENABLED_FEATURES, + features: Array.from(ENABLED_FEATURES), }; } } diff --git a/packages/backend/server/src/core/features/management.ts b/packages/backend/server/src/core/features/management.ts index 3176e4d7d6..c5df3713d1 100644 --- a/packages/backend/server/src/core/features/management.ts +++ b/packages/backend/server/src/core/features/management.ts @@ -115,4 +115,10 @@ export class FeatureManagementService { async listFeatureWorkspaces(feature: FeatureType) { return this.feature.listFeatureWorkspaces(feature); } + + async getUserFeatures(userId: string): Promise { + return (await this.feature.getUserFeatures(userId)).map( + f => f.feature.name + ); + } } diff --git a/packages/backend/server/src/core/features/service.ts b/packages/backend/server/src/core/features/service.ts index 2183b27c5b..d90581be74 100644 --- a/packages/backend/server/src/core/features/service.ts +++ b/packages/backend/server/src/core/features/service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import { UserType } from '../users/types'; import { WorkspaceType } from '../workspaces/types'; import { FeatureConfigType, getFeature } from './feature'; import { FeatureKind, FeatureType } from './types'; @@ -158,7 +157,7 @@ export class FeatureService { return configs.filter(feature => !!feature.feature); } - async listFeatureUsers(feature: FeatureType): Promise { + async listFeatureUsers(feature: FeatureType) { return this.prisma.userFeatures .findMany({ where: { @@ -175,7 +174,7 @@ export class FeatureService { name: true, avatarUrl: true, email: true, - emailVerified: true, + emailVerifiedAt: true, createdAt: true, }, }, diff --git a/packages/backend/server/src/core/quota/schema.ts b/packages/backend/server/src/core/quota/schema.ts index 8b122d6aed..5c607f8a21 100644 --- a/packages/backend/server/src/core/quota/schema.ts +++ b/packages/backend/server/src/core/quota/schema.ts @@ -1,4 +1,4 @@ -import { FeatureKind } from '../features'; +import { FeatureKind } from '../features/types'; import { OneDay, OneGB, OneMB } from './constant'; import { Quota, QuotaType } from './types'; diff --git a/packages/backend/server/src/core/quota/types.ts b/packages/backend/server/src/core/quota/types.ts index 888bedb446..8bc5854066 100644 --- a/packages/backend/server/src/core/quota/types.ts +++ b/packages/backend/server/src/core/quota/types.ts @@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { SafeIntResolver } from 'graphql-scalars'; import { z } from 'zod'; -import { commonFeatureSchema, FeatureKind } from '../features'; +import { commonFeatureSchema, FeatureKind } from '../features/types'; import { ByteUnit, OneDay, OneKB } from './constant'; /// ======== quota define ======== diff --git a/packages/backend/server/src/core/sync/events/events.gateway.ts b/packages/backend/server/src/core/sync/events/events.gateway.ts index 3bac482421..ca7941faeb 100644 --- a/packages/backend/server/src/core/sync/events/events.gateway.ts +++ b/packages/backend/server/src/core/sync/events/events.gateway.ts @@ -14,7 +14,6 @@ import { encodeStateAsUpdate, encodeStateVector } from 'yjs'; import { CallTimer, metrics } from '../../../fundamentals'; import { Auth, CurrentUser } from '../../auth'; import { DocManager } from '../../doc'; -import { UserType } from '../../users'; import { DocID } from '../../utils/doc'; import { PermissionService } from '../../workspaces/permission'; import { Permission } from '../../workspaces/types'; @@ -53,6 +52,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => { if (result instanceof Promise) { return result.catch(e => { metrics.socketio.counter('unhandled_errors').add(1); + new Logger('EventsGateway').error(e, e.stack); return { error: new InternalError(e), }; @@ -139,7 +139,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @Auth() @SubscribeMessage('client-handshake-sync') async handleClientHandshakeSync( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @MessageBody('workspaceId') workspaceId: string, @MessageBody('version') version: string | undefined, @ConnectedSocket() client: Socket @@ -172,7 +172,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @Auth() @SubscribeMessage('client-handshake-awareness') async handleClientHandshakeAwareness( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @MessageBody('workspaceId') workspaceId: string, @MessageBody('version') version: string | undefined, @ConnectedSocket() client: Socket @@ -290,7 +290,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { @SubscribeMessage('doc-load-v2') async loadDocV2( @ConnectedSocket() client: Socket, - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @MessageBody() { workspaceId, @@ -339,6 +339,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { }; } + @Auth() @SubscribeMessage('awareness-init') async handleInitAwareness( @MessageBody() workspaceId: string, diff --git a/packages/backend/server/src/core/users/controller.ts b/packages/backend/server/src/core/user/controller.ts similarity index 100% rename from packages/backend/server/src/core/users/controller.ts rename to packages/backend/server/src/core/user/controller.ts diff --git a/packages/backend/server/src/core/users/index.ts b/packages/backend/server/src/core/user/index.ts similarity index 69% rename from packages/backend/server/src/core/users/index.ts rename to packages/backend/server/src/core/user/index.ts index f25792b026..def37f3ba1 100644 --- a/packages/backend/server/src/core/users/index.ts +++ b/packages/backend/server/src/core/user/index.ts @@ -6,15 +6,15 @@ import { StorageModule } from '../storage'; import { UserAvatarController } from './controller'; import { UserManagementResolver } from './management'; import { UserResolver } from './resolver'; -import { UsersService } from './users'; +import { UserService } from './service'; @Module({ imports: [StorageModule, FeatureModule, QuotaModule], - providers: [UserResolver, UserManagementResolver, UsersService], + providers: [UserResolver, UserManagementResolver, UserService], controllers: [UserAvatarController], - exports: [UsersService], + exports: [UserService], }) -export class UsersModule {} +export class UserModule {} +export { UserService } from './service'; export { UserType } from './types'; -export { UsersService } from './users'; diff --git a/packages/backend/server/src/core/users/management.ts b/packages/backend/server/src/core/user/management.ts similarity index 79% rename from packages/backend/server/src/core/users/management.ts rename to packages/backend/server/src/core/user/management.ts index 08e3bd4def..af6f740f29 100644 --- a/packages/backend/server/src/core/users/management.ts +++ b/packages/backend/server/src/core/user/management.ts @@ -6,23 +6,21 @@ import { import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; import { CloudThrottlerGuard, Throttle } from '../../fundamentals'; -import { Auth, CurrentUser } from '../auth/guard'; -import { AuthService } from '../auth/service'; +import { CurrentUser } from '../auth/current-user'; +import { sessionUser } from '../auth/service'; import { FeatureManagementService } from '../features'; +import { UserService } from './service'; import { UserType } from './types'; -import { UsersService } from './users'; /** * User resolver * All op rate limit: 10 req/m */ @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => UserType) export class UserManagementResolver { constructor( - private readonly auth: AuthService, - private readonly users: UsersService, + private readonly users: UserService, private readonly feature: FeatureManagementService ) {} @@ -34,7 +32,7 @@ export class UserManagementResolver { }) @Mutation(() => Int) async addToEarlyAccess( - @CurrentUser() currentUser: UserType, + @CurrentUser() currentUser: CurrentUser, @Args('email') email: string ): Promise { if (!this.feature.isStaff(currentUser.email)) { @@ -44,7 +42,9 @@ export class UserManagementResolver { if (user) { return this.feature.addEarlyAccess(user.id); } else { - const user = await this.auth.createAnonymousUser(email); + const user = await this.users.createAnonymousUser(email, { + registered: false, + }); return this.feature.addEarlyAccess(user.id); } } @@ -57,7 +57,7 @@ export class UserManagementResolver { }) @Mutation(() => Int) async removeEarlyAccess( - @CurrentUser() currentUser: UserType, + @CurrentUser() currentUser: CurrentUser, @Args('email') email: string ): Promise { if (!this.feature.isStaff(currentUser.email)) { @@ -79,13 +79,15 @@ export class UserManagementResolver { @Query(() => [UserType]) async earlyAccessUsers( @Context() ctx: { isAdminQuery: boolean }, - @CurrentUser() user: UserType + @CurrentUser() user: CurrentUser ): Promise { if (!this.feature.isStaff(user.email)) { throw new ForbiddenException('You are not allowed to do this'); } // allow query other user's subscription ctx.isAdminQuery = true; - return this.feature.listEarlyAccess(); + return this.feature.listEarlyAccess().then(users => { + return users.map(sessionUser); + }); } } diff --git a/packages/backend/server/src/core/users/resolver.ts b/packages/backend/server/src/core/user/resolver.ts similarity index 69% rename from packages/backend/server/src/core/users/resolver.ts rename to packages/backend/server/src/core/user/resolver.ts index e76079d797..d877a154dd 100644 --- a/packages/backend/server/src/core/users/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/graphql'; import { PrismaClient, type User } from '@prisma/client'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; +import { isNil, omitBy } from 'lodash-es'; import { CloudThrottlerGuard, @@ -17,68 +18,38 @@ import { PaymentRequiredException, Throttle, } from '../../fundamentals'; -import { Auth, CurrentUser, Public, Publicable } from '../auth/guard'; -import { FeatureManagementService } from '../features'; +import { CurrentUser } from '../auth/current-user'; +import { Public } from '../auth/guard'; +import { sessionUser } from '../auth/service'; +import { FeatureManagementService, FeatureType } from '../features'; import { QuotaService } from '../quota'; import { AvatarStorage } from '../storage'; +import { UserService } from './service'; import { DeleteAccount, RemoveAvatar, + UpdateUserInput, UserOrLimitedUser, UserQuotaType, UserType, } from './types'; -import { UsersService } from './users'; /** * User resolver * All op rate limit: 10 req/m */ @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => UserType) export class UserResolver { constructor( private readonly prisma: PrismaClient, private readonly storage: AvatarStorage, - private readonly users: UsersService, + private readonly users: UserService, private readonly feature: FeatureManagementService, private readonly quota: QuotaService, private readonly event: EventEmitter ) {} - @Throttle({ - default: { - limit: 10, - ttl: 60, - }, - }) - @Publicable() - @Query(() => UserType, { - name: 'currentUser', - description: 'Get current user', - nullable: true, - }) - async currentUser(@CurrentUser() user?: UserType) { - if (!user) { - return null; - } - - const storedUser = await this.users.findUserById(user.id); - if (!storedUser) { - throw new BadRequestException(`User ${user.id} not found in db`); - } - return { - id: storedUser.id, - name: storedUser.name, - email: storedUser.email, - emailVerified: storedUser.emailVerified, - avatarUrl: storedUser.avatarUrl, - createdAt: storedUser.createdAt, - hasPassword: !!storedUser.password, - }; - } - @Throttle({ default: { limit: 10, @@ -92,9 +63,9 @@ export class UserResolver { }) @Public() async user( - @CurrentUser() currentUser?: UserType, + @CurrentUser() currentUser?: CurrentUser, @Args('email') email?: string - ) { + ): Promise { if (!email || !(await this.feature.canEarlyAccess(email))) { throw new PaymentRequiredException( `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information` @@ -102,16 +73,19 @@ export class UserResolver { } // TODO: need to limit a user can only get another user witch is in the same workspace - const user = await this.users.findUserByEmail(email); - if (currentUser) return user; + const user = await this.users.findUserWithHashedPasswordByEmail(email); // return empty response when user not exists if (!user) return null; + if (currentUser) { + return sessionUser(user); + } + // only return limited info when not logged in return { - email: user?.email, - hasPassword: !!user?.password, + email: user.email, + hasPassword: !!user.password, }; } @@ -128,12 +102,21 @@ export class UserResolver { name: 'invoiceCount', description: 'Get user invoice count', }) - async invoiceCount(@CurrentUser() user: UserType) { + async invoiceCount(@CurrentUser() user: CurrentUser) { return this.prisma.userInvoice.count({ where: { userId: user.id }, }); } + @Throttle({ default: { limit: 10, ttl: 60 } }) + @ResolveField(() => [FeatureType], { + name: 'features', + description: 'Enabled features of a user', + }) + async userFeatures(@CurrentUser() user: CurrentUser) { + return this.feature.getUserFeatures(user.id); + } + @Throttle({ default: { limit: 10, @@ -145,7 +128,7 @@ export class UserResolver { description: 'Upload user avatar', }) async uploadAvatar( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args({ name: 'avatar', type: () => GraphQLUpload }) avatar: FileUpload ) { @@ -169,6 +152,33 @@ export class UserResolver { }); } + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Mutation(() => UserType, { + name: 'updateProfile', + }) + async updateUserProfile( + @CurrentUser() user: CurrentUser, + @Args('input', { type: () => UpdateUserInput }) input: UpdateUserInput + ): Promise { + input = omitBy(input, isNil); + + if (Object.keys(input).length === 0) { + return user; + } + + return sessionUser( + await this.prisma.user.update({ + where: { id: user.id }, + data: input, + }) + ); + } + @Throttle({ default: { limit: 10, @@ -179,7 +189,7 @@ export class UserResolver { name: 'removeAvatar', description: 'Remove user avatar', }) - async removeAvatar(@CurrentUser() user: UserType) { + async removeAvatar(@CurrentUser() user: CurrentUser) { if (!user) { throw new BadRequestException(`User not found`); } @@ -197,7 +207,9 @@ export class UserResolver { }, }) @Mutation(() => DeleteAccount) - async deleteAccount(@CurrentUser() user: UserType): Promise { + async deleteAccount( + @CurrentUser() user: CurrentUser + ): Promise { const deletedUser = await this.users.deleteUser(user.id); this.event.emit('user.deleted', deletedUser); return { success: true }; diff --git a/packages/backend/server/src/core/user/service.ts b/packages/backend/server/src/core/user/service.ts new file mode 100644 index 0000000000..4c60f00975 --- /dev/null +++ b/packages/backend/server/src/core/user/service.ts @@ -0,0 +1,133 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Prisma, PrismaClient } from '@prisma/client'; + +import { Quota_FreePlanV1_1 } from '../quota/schema'; + +@Injectable() +export class UserService { + defaultUserSelect = { + id: true, + name: true, + email: true, + emailVerifiedAt: true, + avatarUrl: true, + registered: true, + } satisfies Prisma.UserSelect; + + constructor(private readonly prisma: PrismaClient) {} + + get userCreatingData() { + return { + name: 'Unnamed', + features: { + create: { + reason: 'created by invite sign up', + activated: true, + feature: { + connect: { + feature_version: Quota_FreePlanV1_1, + }, + }, + }, + }, + }; + } + + async createUser(data: Prisma.UserCreateInput) { + return this.prisma.user.create({ + data: { + ...this.userCreatingData, + ...data, + }, + }); + } + + async createAnonymousUser( + email: string, + data?: Partial + ) { + const user = await this.findUserByEmail(email); + + if (user) { + throw new BadRequestException('Email already exists'); + } + + return this.createUser({ + email, + name: 'Unnamed', + ...data, + }); + } + + async findUserById(id: string) { + return this.prisma.user + .findUnique({ + where: { id }, + select: this.defaultUserSelect, + }) + .catch(() => { + return null; + }); + } + + async findUserByEmail(email: string) { + return this.prisma.user.findFirst({ + where: { + email: { + equals: email, + mode: 'insensitive', + }, + }, + select: this.defaultUserSelect, + }); + } + + /** + * supposed to be used only for `Credential SignIn` + */ + async findUserWithHashedPasswordByEmail(email: string) { + return this.prisma.user.findFirst({ + where: { + email: { + equals: email, + mode: 'insensitive', + }, + }, + }); + } + + async findOrCreateUser( + email: string, + data?: Partial + ) { + const user = await this.findUserByEmail(email); + if (user) { + return user; + } + return this.createAnonymousUser(email, data); + } + + async fulfillUser( + email: string, + data: Partial< + Pick + > + ) { + return this.prisma.user.upsert({ + select: this.defaultUserSelect, + where: { + email, + }, + update: data, + create: { + email, + ...this.userCreatingData, + ...data, + }, + }); + } + + async deleteUser(id: string) { + return this.prisma.user.delete({ where: { id } }); + } +} diff --git a/packages/backend/server/src/core/users/types.ts b/packages/backend/server/src/core/user/types.ts similarity index 73% rename from packages/backend/server/src/core/users/types.ts rename to packages/backend/server/src/core/user/types.ts index eb41b31618..2f297627ea 100644 --- a/packages/backend/server/src/core/users/types.ts +++ b/packages/backend/server/src/core/user/types.ts @@ -1,7 +1,15 @@ -import { createUnionType, Field, ID, ObjectType } from '@nestjs/graphql'; +import { + createUnionType, + Field, + ID, + InputType, + ObjectType, +} from '@nestjs/graphql'; import type { User } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; +import { CurrentUser } from '../auth/current-user'; + @ObjectType('UserQuotaHumanReadable') export class UserQuotaHumanReadableType { @Field({ name: 'name' }) @@ -42,7 +50,7 @@ export class UserQuotaType { } @ObjectType() -export class UserType implements Partial { +export class UserType implements CurrentUser { @Field(() => ID) id!: string; @@ -53,19 +61,25 @@ export class UserType implements Partial { email!: string; @Field(() => String, { description: 'User avatar url', nullable: true }) - avatarUrl: string | null = null; + avatarUrl!: string | null; - @Field(() => Date, { description: 'User email verified', nullable: true }) - emailVerified: Date | null = null; - - @Field({ description: 'User created date', nullable: true }) - createdAt!: Date; + @Field(() => Boolean, { + description: 'User email verified', + }) + emailVerified!: boolean; @Field(() => Boolean, { description: 'User password has been set', nullable: true, }) - hasPassword?: boolean; + hasPassword!: boolean | null; + + @Field(() => Date, { + deprecationReason: 'useless', + description: 'User email verified', + nullable: true, + }) + createdAt?: Date | null; } @ObjectType() @@ -77,7 +91,7 @@ export class LimitedUserType implements Partial { description: 'User password has been set', nullable: true, }) - hasPassword?: boolean; + hasPassword!: boolean | null; } export const UserOrLimitedUser = createUnionType({ @@ -101,3 +115,9 @@ export class RemoveAvatar { @Field() success!: boolean; } + +@InputType() +export class UpdateUserInput implements Partial { + @Field({ description: 'User name', nullable: true }) + name?: string; +} diff --git a/packages/backend/server/src/core/users/users.ts b/packages/backend/server/src/core/users/users.ts deleted file mode 100644 index 06c8aa0b62..0000000000 --- a/packages/backend/server/src/core/users/users.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; - -@Injectable() -export class UsersService { - constructor(private readonly prisma: PrismaClient) {} - - async findUserByEmail(email: string) { - return this.prisma.user.findFirst({ - where: { - email: { - equals: email, - mode: 'insensitive', - }, - }, - }); - } - - async findUserById(id: string) { - return this.prisma.user - .findUnique({ - where: { id }, - }) - .catch(() => { - return null; - }); - } - - async deleteUser(id: string) { - return this.prisma.user.delete({ where: { id } }); - } -} diff --git a/packages/backend/server/src/core/utils/validators.ts b/packages/backend/server/src/core/utils/validators.ts new file mode 100644 index 0000000000..547ae8a1e3 --- /dev/null +++ b/packages/backend/server/src/core/utils/validators.ts @@ -0,0 +1,55 @@ +import { BadRequestException } from '@nestjs/common'; +import z from 'zod'; + +function getAuthCredentialValidator() { + const email = z.string().email({ message: 'Invalid email address' }); + let password = z.string(); + + const minPasswordLength = AFFiNE.node.prod ? 8 : 1; + password = password + .min(minPasswordLength, { + message: `Password must be ${minPasswordLength} or more charactors long`, + }) + .max(20, { message: 'Password must be 20 or fewer charactors long' }); + + return z + .object({ + email, + password, + }) + .required(); +} + +function assertValid(z: z.ZodType, value: unknown) { + const result = z.safeParse(value); + + if (!result.success) { + const firstIssue = result.error.issues.at(0); + if (firstIssue) { + throw new BadRequestException(firstIssue.message); + } else { + throw new BadRequestException('Invalid credential'); + } + } +} + +export function assertValidEmail(email: string) { + assertValid(getAuthCredentialValidator().shape.email, email); +} + +export function assertValidPassword(password: string) { + assertValid(getAuthCredentialValidator().shape.password, password); +} + +export function assertValidCredential(credential: { + email: string; + password: string; +}) { + assertValid(getAuthCredentialValidator(), credential); +} + +export const validators = { + assertValidEmail, + assertValidPassword, + assertValidCredential, +}; diff --git a/packages/backend/server/src/core/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts index 006518acd1..f134604818 100644 --- a/packages/backend/server/src/core/workspaces/controller.ts +++ b/packages/backend/server/src/core/workspaces/controller.ts @@ -11,10 +11,9 @@ import { PrismaClient } from '@prisma/client'; import type { Response } from 'express'; import { CallTimer } from '../../fundamentals'; -import { Auth, CurrentUser, Publicable } from '../auth'; +import { CurrentUser, Public } from '../auth'; import { DocHistoryManager, DocManager } from '../doc'; import { WorkspaceBlobStorage } from '../storage'; -import { UserType } from '../users'; import { DocID } from '../utils/doc'; import { PermissionService, PublicPageMode } from './permission'; import { Permission } from './types'; @@ -33,6 +32,7 @@ export class WorkspacesController { // get workspace blob // // NOTE: because graphql can't represent a File, so we have to use REST API to get blob + @Public() @Get('/:id/blobs/:name') @CallTimer('controllers', 'workspace_get_blob') async blob( @@ -62,12 +62,11 @@ export class WorkspacesController { } // get doc binary + @Public() @Get('/:id/docs/:guid') - @Auth() - @Publicable() @CallTimer('controllers', 'workspace_get_doc') async doc( - @CurrentUser() user: UserType | undefined, + @CurrentUser() user: CurrentUser | undefined, @Param('id') ws: string, @Param('guid') guid: string, @Res() res: Response @@ -112,10 +111,9 @@ export class WorkspacesController { } @Get('/:id/docs/:guid/histories/:timestamp') - @Auth() @CallTimer('controllers', 'workspace_get_history') async history( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Param('id') ws: string, @Param('guid') guid: string, @Param('timestamp') timestamp: string, diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index 36a822e7b3..95c7b4bdb4 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -4,7 +4,7 @@ import { DocModule } from '../doc'; import { FeatureModule } from '../features'; import { QuotaModule } from '../quota'; import { StorageModule } from '../storage'; -import { UsersService } from '../users'; +import { UserModule } from '../user'; import { WorkspacesController } from './controller'; import { WorkspaceManagementResolver } from './management'; import { PermissionService } from './permission'; @@ -16,13 +16,12 @@ import { } from './resolvers'; @Module({ - imports: [DocModule, FeatureModule, QuotaModule, StorageModule], + imports: [DocModule, FeatureModule, QuotaModule, StorageModule, UserModule], controllers: [WorkspacesController], providers: [ WorkspaceResolver, WorkspaceManagementResolver, PermissionService, - UsersService, PagePermissionResolver, DocHistoryResolver, WorkspaceBlobResolver, diff --git a/packages/backend/server/src/core/workspaces/management.ts b/packages/backend/server/src/core/workspaces/management.ts index f4e2752290..c8625c4d43 100644 --- a/packages/backend/server/src/core/workspaces/management.ts +++ b/packages/backend/server/src/core/workspaces/management.ts @@ -10,14 +10,12 @@ import { } from '@nestjs/graphql'; import { CloudThrottlerGuard, Throttle } from '../../fundamentals'; -import { Auth, CurrentUser } from '../auth'; +import { CurrentUser } from '../auth'; import { FeatureManagementService, FeatureType } from '../features'; -import { UserType } from '../users'; import { PermissionService } from './permission'; import { WorkspaceType } from './types'; @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => WorkspaceType) export class WorkspaceManagementResolver { constructor( @@ -33,7 +31,7 @@ export class WorkspaceManagementResolver { }) @Mutation(() => Int) async addWorkspaceFeature( - @CurrentUser() currentUser: UserType, + @CurrentUser() currentUser: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('feature', { type: () => FeatureType }) feature: FeatureType ): Promise { @@ -52,7 +50,7 @@ export class WorkspaceManagementResolver { }) @Mutation(() => Int) async removeWorkspaceFeature( - @CurrentUser() currentUser: UserType, + @CurrentUser() currentUser: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('feature', { type: () => FeatureType }) feature: FeatureType ): Promise { @@ -71,7 +69,7 @@ export class WorkspaceManagementResolver { }) @Query(() => [WorkspaceType]) async listWorkspaceFeatures( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('feature', { type: () => FeatureType }) feature: FeatureType ): Promise { if (!this.feature.isStaff(user.email)) { @@ -83,7 +81,7 @@ export class WorkspaceManagementResolver { @Mutation(() => Boolean) async setWorkspaceExperimentalFeature( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('feature', { type: () => FeatureType }) feature: FeatureType, @Args('enable') enable: boolean @@ -117,7 +115,7 @@ export class WorkspaceManagementResolver { complexity: 2, }) async availableFeatures( - @CurrentUser() user: UserType + @CurrentUser() user: CurrentUser ): Promise { const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email); if (isEarlyAccessUser) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index 5d859d0c42..847ee1c909 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -22,16 +22,14 @@ import { MakeCache, PreventCache, } from '../../../fundamentals'; -import { Auth, CurrentUser } from '../../auth'; +import { CurrentUser } from '../../auth'; import { FeatureManagementService, FeatureType } from '../../features'; import { QuotaManagementService } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; -import { UserType } from '../../users'; import { PermissionService } from '../permission'; import { Permission, WorkspaceBlobSizes, WorkspaceType } from '../types'; @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => WorkspaceType) export class WorkspaceBlobResolver { logger = new Logger(WorkspaceBlobResolver.name); @@ -47,7 +45,7 @@ export class WorkspaceBlobResolver { complexity: 2, }) async blobs( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Parent() workspace: WorkspaceType ) { await this.permissions.checkWorkspace(workspace.id, user.id); @@ -74,7 +72,7 @@ export class WorkspaceBlobResolver { }) @MakeCache(['blobs'], ['workspaceId']) async listBlobs( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { await this.permissions.checkWorkspace(workspaceId, user.id); @@ -90,7 +88,7 @@ export class WorkspaceBlobResolver { @Query(() => WorkspaceBlobSizes, { deprecationReason: 'use `user.storageUsage` instead', }) - async collectAllBlobSizes(@CurrentUser() user: UserType) { + async collectAllBlobSizes(@CurrentUser() user: CurrentUser) { const size = await this.quota.getUserUsage(user.id); return { size }; } @@ -102,7 +100,7 @@ export class WorkspaceBlobResolver { deprecationReason: 'no more needed', }) async checkBlobSize( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('size', { type: () => SafeIntResolver }) blobSize: number ) { @@ -121,7 +119,7 @@ export class WorkspaceBlobResolver { @Mutation(() => String) @PreventCache(['blobs'], ['workspaceId']) async setBlob( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args({ name: 'blob', type: () => GraphQLUpload }) blob: FileUpload @@ -199,7 +197,7 @@ export class WorkspaceBlobResolver { @Mutation(() => Boolean) @PreventCache(['blobs'], ['workspaceId']) async deleteBlob( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('hash') name: string ) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/history.ts b/packages/backend/server/src/core/workspaces/resolvers/history.ts index 3b8475f3a4..deef0851a4 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/history.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/history.ts @@ -13,9 +13,8 @@ import { import type { SnapshotHistory } from '@prisma/client'; import { CloudThrottlerGuard } from '../../../fundamentals'; -import { Auth, CurrentUser } from '../../auth'; +import { CurrentUser } from '../../auth'; import { DocHistoryManager } from '../../doc'; -import { UserType } from '../../users'; import { DocID } from '../../utils/doc'; import { PermissionService } from '../permission'; import { Permission, WorkspaceType } from '../types'; @@ -68,10 +67,9 @@ export class DocHistoryResolver { ); } - @Auth() @Mutation(() => Date) async recoverDoc( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('guid') guid: string, @Args({ name: 'timestamp', type: () => GraphQLISODateTime }) timestamp: Date diff --git a/packages/backend/server/src/core/workspaces/resolvers/page.ts b/packages/backend/server/src/core/workspaces/resolvers/page.ts index 8364a351b5..773165d4fa 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/page.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/page.ts @@ -15,8 +15,7 @@ import { } from '@prisma/client'; import { CloudThrottlerGuard } from '../../../fundamentals'; -import { Auth, CurrentUser } from '../../auth'; -import { UserType } from '../../users'; +import { CurrentUser } from '../../auth'; import { DocID } from '../../utils/doc'; import { PermissionService, PublicPageMode } from '../permission'; import { Permission, WorkspaceType } from '../types'; @@ -42,7 +41,6 @@ class WorkspacePage implements Partial { } @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => WorkspaceType) export class PagePermissionResolver { constructor( @@ -90,7 +88,7 @@ export class PagePermissionResolver { deprecationReason: 'renamed to publicPage', }) async deprecatedSharePage( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('pageId') pageId: string ) { @@ -100,7 +98,7 @@ export class PagePermissionResolver { @Mutation(() => WorkspacePage) async publishPage( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('pageId') pageId: string, @Args({ @@ -134,7 +132,7 @@ export class PagePermissionResolver { deprecationReason: 'use revokePublicPage', }) async deprecatedRevokePage( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('pageId') pageId: string ) { @@ -144,7 +142,7 @@ export class PagePermissionResolver { @Mutation(() => WorkspacePage) async revokePublicPage( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('pageId') pageId: string ) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 87fe5bd46d..d322a69c1b 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -15,7 +15,7 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { PrismaClient, type User } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; import { getStreamAsBuffer } from 'get-stream'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { applyUpdate, Doc } from 'yjs'; @@ -27,11 +27,10 @@ import { MailService, Throttle, } from '../../../fundamentals'; -import { Auth, CurrentUser, Public } from '../../auth'; -import { AuthService } from '../../auth/service'; +import { CurrentUser, Public } from '../../auth'; import { QuotaManagementService, QuotaQueryType } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; -import { UsersService, UserType } from '../../users'; +import { UserService, UserType } from '../../user'; import { PermissionService } from '../permission'; import { InvitationType, @@ -48,18 +47,16 @@ import { defaultWorkspaceAvatar } from '../utils'; * Other rate limit: 120 req/m */ @UseGuards(CloudThrottlerGuard) -@Auth() @Resolver(() => WorkspaceType) export class WorkspaceResolver { private readonly logger = new Logger(WorkspaceResolver.name); constructor( - private readonly auth: AuthService, private readonly mailer: MailService, private readonly prisma: PrismaClient, private readonly permissions: PermissionService, private readonly quota: QuotaManagementService, - private readonly users: UsersService, + private readonly users: UserService, private readonly event: EventEmitter, private readonly blobStorage: WorkspaceBlobStorage ) {} @@ -69,7 +66,7 @@ export class WorkspaceResolver { complexity: 2, }) async permission( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Parent() workspace: WorkspaceType ) { // may applied in workspaces query @@ -160,7 +157,7 @@ export class WorkspaceResolver { complexity: 2, }) async isOwner( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string ) { const data = await this.permissions.tryGetWorkspaceOwner(workspaceId); @@ -172,7 +169,7 @@ export class WorkspaceResolver { description: 'Get all accessible workspaces for current user', complexity: 2, }) - async workspaces(@CurrentUser() user: User) { + async workspaces(@CurrentUser() user: CurrentUser) { const data = await this.prisma.workspaceUserPermission.findMany({ where: { userId: user.id, @@ -216,7 +213,7 @@ export class WorkspaceResolver { @Query(() => WorkspaceType, { description: 'Get workspace by id', }) - async workspace(@CurrentUser() user: UserType, @Args('id') id: string) { + async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) { await this.permissions.checkWorkspace(id, user.id); const workspace = await this.prisma.workspace.findUnique({ where: { id } }); @@ -231,7 +228,7 @@ export class WorkspaceResolver { description: 'Create a new workspace', }) async createWorkspace( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, // we no longer support init workspace with a preload file // use sync system to uploading them once created @Args({ name: 'init', type: () => GraphQLUpload, nullable: true }) @@ -289,7 +286,7 @@ export class WorkspaceResolver { description: 'Update workspace', }) async updateWorkspace( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args({ name: 'input', type: () => UpdateWorkspaceInput }) { id, ...updates }: UpdateWorkspaceInput ) { @@ -304,7 +301,10 @@ export class WorkspaceResolver { } @Mutation(() => Boolean) - async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) { + async deleteWorkspace( + @CurrentUser() user: CurrentUser, + @Args('id') id: string + ) { await this.permissions.checkWorkspace(id, user.id, Permission.Owner); await this.prisma.workspace.delete({ @@ -320,7 +320,7 @@ export class WorkspaceResolver { @Mutation(() => String) async invite( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('email') email: string, @Args('permission', { type: () => Permission }) permission: Permission, @@ -358,7 +358,9 @@ export class WorkspaceResolver { // only invite if the user is not already in the workspace if (originRecord) return originRecord.id; } else { - target = await this.auth.createAnonymousUser(email); + target = await this.users.createAnonymousUser(email, { + registered: false, + }); } const inviteId = await this.permissions.grant( @@ -470,7 +472,7 @@ export class WorkspaceResolver { @Mutation(() => Boolean) async revoke( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { @@ -514,7 +516,7 @@ export class WorkspaceResolver { @Mutation(() => Boolean) async leaveWorkspace( - @CurrentUser() user: UserType, + @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, @Args('workspaceName') workspaceName: string, @Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean diff --git a/packages/backend/server/src/core/workspaces/types.ts b/packages/backend/server/src/core/workspaces/types.ts index e959a1d1f4..f85a5a6f22 100644 --- a/packages/backend/server/src/core/workspaces/types.ts +++ b/packages/backend/server/src/core/workspaces/types.ts @@ -11,7 +11,7 @@ import { import type { Workspace } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; -import { UserType } from '../users/types'; +import { UserType } from '../user/types'; export enum Permission { Read = 0, diff --git a/packages/backend/server/src/data/commands/run.ts b/packages/backend/server/src/data/commands/run.ts index 136b7df0d7..7d4df391f5 100644 --- a/packages/backend/server/src/data/commands/run.ts +++ b/packages/backend/server/src/data/commands/run.ts @@ -1,6 +1,6 @@ import { readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { Logger } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; @@ -25,7 +25,7 @@ export async function collectMigrations(): Promise { const migrations: Migration[] = await Promise.all( migrationFiles.map(async file => { - return import(file).then(mod => { + return import(pathToFileURL(file).href).then(mod => { const migration = mod[Object.keys(mod)[0]]; return { diff --git a/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts b/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts index c0bd3493fd..0d13b0724d 100644 --- a/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts +++ b/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts @@ -1,13 +1,15 @@ import { ModuleRef } from '@nestjs/core'; -import { hash } from '@node-rs/argon2'; import { PrismaClient } from '@prisma/client'; -import { Config } from '../../fundamentals'; +import { UserService } from '../../core/user'; +import { Config, CryptoHelper } from '../../fundamentals'; export class SelfHostAdmin1605053000403 { // do the migration - static async up(db: PrismaClient, ref: ModuleRef) { + static async up(_db: PrismaClient, ref: ModuleRef) { const config = ref.get(Config, { strict: false }); + const crypto = ref.get(CryptoHelper, { strict: false }); + const user = ref.get(UserService, { strict: false }); if (config.isSelfhosted) { if ( !process.env.AFFINE_ADMIN_EMAIL || @@ -17,13 +19,12 @@ export class SelfHostAdmin1605053000403 { 'You have to set AFFINE_ADMIN_EMAIL and AFFINE_ADMIN_PASSWORD environment variables to generate the initial user for self-hosted AFFiNE Server.' ); } - await db.user.create({ - data: { - name: 'AFFINE First User', - email: process.env.AFFINE_ADMIN_EMAIL, - emailVerified: new Date(), - password: await hash(process.env.AFFINE_ADMIN_PASSWORD), - }, + await user.findOrCreateUser(process.env.AFFINE_ADMIN_EMAIL, { + name: 'AFFINE First User', + emailVerifiedAt: new Date(), + password: await crypto.encryptPassword( + process.env.AFFINE_ADMIN_PASSWORD + ), }); } } diff --git a/packages/backend/server/src/data/migrations/1710319359062-oauth.ts b/packages/backend/server/src/data/migrations/1710319359062-oauth.ts new file mode 100644 index 0000000000..457cdc1f99 --- /dev/null +++ b/packages/backend/server/src/data/migrations/1710319359062-oauth.ts @@ -0,0 +1,39 @@ +import { PrismaClient } from '@prisma/client'; + +import { loop } from './utils/loop'; + +export class Oauth1710319359062 { + // do the migration + static async up(db: PrismaClient) { + await loop(async (skip, take) => { + const oldRecords = await db.deprecatedNextAuthAccount.findMany({ + skip, + take, + orderBy: { + providerAccountId: 'asc', + }, + }); + + await db.connectedAccount.createMany({ + data: oldRecords.map(record => ({ + userId: record.userId, + provider: record.provider, + scope: record.scope, + providerAccountId: record.providerAccountId, + accessToken: record.access_token, + refreshToken: record.refresh_token, + expiresAt: record.expires_at + ? new Date(record.expires_at * 1000) + : null, + })), + }); + + return oldRecords.length; + }, 10); + } + + // revert the migration + static async down(db: PrismaClient) { + await db.connectedAccount.deleteMany({}); + } +} diff --git a/packages/backend/server/src/data/migrations/utils/loop.ts b/packages/backend/server/src/data/migrations/utils/loop.ts new file mode 100644 index 0000000000..2beb886435 --- /dev/null +++ b/packages/backend/server/src/data/migrations/utils/loop.ts @@ -0,0 +1,13 @@ +export async function loop( + batchFn: (skip: number, take: number) => Promise, + chunkSize: number = 100 +) { + let turn = 0; + let last = chunkSize; + + while (last === chunkSize) { + last = await batchFn(chunkSize * turn, chunkSize); + + turn++; + } +} diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index 2dc04cad3e..a81d53be75 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -87,6 +87,22 @@ export interface AFFiNEConfig { sync: boolean; }; + /** + * Application secrets for authentication and data encryption + */ + secrets: { + /** + * Application public key + * + */ + publicKey: string; + /** + * Application private key + * + */ + privateKey: string; + }; + /** * Deployment environment */ @@ -204,67 +220,32 @@ export interface AFFiNEConfig { * authentication config */ auth: { + session: { + /** + * Application auth expiration time in seconds + * + * @default 15 days + */ + ttl: number; + }; + /** - * Application access token expiration time + * Application access token config */ - readonly accessTokenExpiresIn: number; - /** - * Application refresh token expiration time - */ - readonly refreshTokenExpiresIn: number; - /** - * Add some leeway (in seconds) to the exp and nbf validation to account for clock skew. - * Defaults to 60 if omitted. - */ - readonly leeway: number; - /** - * Application public key - * - */ - readonly publicKey: string; - /** - * Application private key - * - */ - readonly privateKey: string; - /** - * whether allow user to signup with email directly - */ - enableSignup: boolean; - /** - * whether allow user to signup by oauth providers - */ - enableOauth: boolean; - /** - * NEXTAUTH_SECRET - */ - nextAuthSecret: string; - /** - * all available oauth providers - */ - oauthProviders: Partial< - Record< - ExternalAccount, - { - enabled: boolean; - clientId: string; - clientSecret: string; - /** - * uri to start oauth flow - */ - authorizationUri?: string; - /** - * uri to authenticate `access_token` when user is redirected back from oauth provider with `code` - */ - accessTokenUri?: string; - /** - * uri to get user info with authenticated `access_token` - */ - userInfoUri?: string; - args?: Record; - } - > - >; + accessToken: { + /** + * Application access token expiration time in seconds + * + * @default 7 days + */ + ttl: number; + /** + * Application refresh token expiration time in seconds + * + * @default 30 days + */ + refreshTokenTtl: number; + }; captcha: { /** * whether to enable captcha diff --git a/packages/backend/server/src/fundamentals/config/default.ts b/packages/backend/server/src/fundamentals/config/default.ts index 9c884a136f..f41bc16e7c 100644 --- a/packages/backend/server/src/fundamentals/config/default.ts +++ b/packages/backend/server/src/fundamentals/config/default.ts @@ -3,7 +3,6 @@ import { createPrivateKey, createPublicKey } from 'node:crypto'; import { merge } from 'lodash-es'; -import parse from 'parse-duration'; import pkg from '../../../package.json' assert { type: 'json' }; import { @@ -23,7 +22,9 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI 3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg== -----END EC PRIVATE KEY-----`; -const jwtKeyPair = (function () { +const ONE_DAY_IN_SEC = 60 * 60 * 24; + +const keyPair = (function () { const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey; const privateKey = createPrivateKey({ key: Buffer.from(AUTH_PRIVATE_KEY), @@ -114,6 +115,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { get deploy() { return !this.node.dev && !this.node.test; }, + secrets: { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + }, featureFlags: { earlyAccessPreview: false, syncClientVersionCheck: false, @@ -145,11 +150,13 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { playground: true, }, auth: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accessTokenExpiresIn: parse('1h')! / 1000, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - refreshTokenExpiresIn: parse('7d')! / 1000, - leeway: 60, + session: { + ttl: 15 * ONE_DAY_IN_SEC, + }, + accessToken: { + ttl: 7 * ONE_DAY_IN_SEC, + refreshTokenTtl: 30 * ONE_DAY_IN_SEC, + }, captcha: { enable: false, turnstile: { @@ -159,14 +166,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { bits: 20, }, }, - privateKey: jwtKeyPair.privateKey, - publicKey: jwtKeyPair.publicKey, - enableSignup: true, - enableOauth: false, - get nextAuthSecret() { - return this.privateKey; - }, - oauthProviders: {}, }, storage: getDefaultAFFiNEStorageConfig(), rateLimiter: { @@ -188,10 +187,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { enabled: false, }, plugins: { - enabled: [], + enabled: new Set(), use(plugin, config) { this[plugin] = merge(this[plugin], config || {}); - this.enabled.push(plugin); + this.enabled.add(plugin); }, }, } satisfies AFFiNEConfig; diff --git a/packages/backend/server/src/fundamentals/helpers/__tests__/crypto.spec.ts b/packages/backend/server/src/fundamentals/helpers/__tests__/crypto.spec.ts new file mode 100644 index 0000000000..5ab3448ad2 --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/__tests__/crypto.spec.ts @@ -0,0 +1,105 @@ +import { createPrivateKey, createPublicKey } from 'node:crypto'; + +import { Test } from '@nestjs/testing'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; + +import { ConfigModule } from '../../config'; +import { CryptoHelper } from '../crypto'; + +const test = ava as TestFn<{ + crypto: CryptoHelper; +}>; + +const key = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49 +AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI +3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg== +-----END EC PRIVATE KEY-----`; +const privateKey = createPrivateKey({ + key, + format: 'pem', + type: 'sec1', +}) + .export({ + type: 'pkcs8', + format: 'pem', + }) + .toString('utf8'); + +const publicKey = createPublicKey({ + key, + format: 'pem', + type: 'spki', +}) + .export({ + format: 'pem', + type: 'spki', + }) + .toString('utf8'); + +test.beforeEach(async t => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + secrets: { + publicKey, + privateKey, + }, + }), + ], + providers: [CryptoHelper], + }).compile(); + + t.context.crypto = module.get(CryptoHelper); +}); + +test('should be able to sign and verify', t => { + const data = 'hello world'; + const signature = t.context.crypto.sign(data); + t.true(t.context.crypto.verify(data, signature)); + t.false(t.context.crypto.verify(data, 'fake-signature')); +}); + +test('should be able to encrypt and decrypt', t => { + const data = 'top secret'; + const stub = Sinon.stub(t.context.crypto, 'randomBytes').returns( + Buffer.alloc(12, 0) + ); + + const encrypted = t.context.crypto.encrypt(data); + const decrypted = t.context.crypto.decrypt(encrypted); + + // we are using a stub to make sure the iv is always 0, + // the encrypted result will always be the same + t.is(encrypted, 'AAAAAAAAAAAAAAAAWUDlJRhzP+SZ3avvmLcgnou+q4E11w=='); + t.is(decrypted, data); + + stub.restore(); +}); + +test('should be able to get random bytes', t => { + const bytes = t.context.crypto.randomBytes(); + t.is(bytes.length, 12); + const bytes2 = t.context.crypto.randomBytes(); + + t.notDeepEqual(bytes, bytes2); +}); + +test('should be able to digest', t => { + const data = 'hello world'; + const hash = t.context.crypto.sha256(data).toString('base64'); + t.is(hash, 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek='); +}); + +test('should be able to safe compare', t => { + t.true(t.context.crypto.compare('abc', 'abc')); + t.false(t.context.crypto.compare('abc', 'def')); +}); + +test('should be able to hash and verify password', async t => { + const password = 'mySecurePassword'; + const hash = await t.context.crypto.encryptPassword(password); + t.true(await t.context.crypto.verifyPassword(password, hash)); + t.false(await t.context.crypto.verifyPassword('wrong-password', hash)); +}); diff --git a/packages/backend/server/src/fundamentals/helpers/__tests__/url.spec.ts b/packages/backend/server/src/fundamentals/helpers/__tests__/url.spec.ts new file mode 100644 index 0000000000..7a8e8cb40c --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/__tests__/url.spec.ts @@ -0,0 +1,72 @@ +import { Test } from '@nestjs/testing'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; + +import { ConfigModule } from '../../config'; +import { URLHelper } from '../url'; + +const test = ava as TestFn<{ + url: URLHelper; +}>; + +test.beforeEach(async t => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + host: 'app.affine.local', + port: 3010, + https: true, + }), + ], + providers: [URLHelper], + }).compile(); + + t.context.url = module.get(URLHelper); +}); + +test('can get home page', t => { + t.is(t.context.url.home, 'https://app.affine.local'); +}); + +test('can stringify query', t => { + t.is(t.context.url.stringify({ a: 1, b: 2 }), 'a=1&b=2'); + t.is(t.context.url.stringify({ a: 1, b: '/path' }), 'a=1&b=%2Fpath'); +}); + +test('can create link', t => { + t.is(t.context.url.link('/path'), 'https://app.affine.local/path'); + t.is( + t.context.url.link('/path', { a: 1, b: 2 }), + 'https://app.affine.local/path?a=1&b=2' + ); + t.is( + t.context.url.link('/path', { a: 1, b: '/path' }), + 'https://app.affine.local/path?a=1&b=%2Fpath' + ); +}); + +test('can safe redirect', t => { + const res = { + redirect: (to: string) => to, + } as any; + + const spy = Sinon.spy(res, 'redirect'); + function allow(to: string) { + t.context.url.safeRedirect(res, to); + t.true(spy.calledOnceWith(to)); + spy.resetHistory(); + } + + function deny(to: string) { + t.context.url.safeRedirect(res, to); + t.true(spy.calledOnceWith(t.context.url.home)); + spy.resetHistory(); + } + + [ + 'https://app.affine.local', + 'https://app.affine.local/path', + 'https://app.affine.local/path?query=1', + ].forEach(allow); + ['https://other.domain.com', 'a://invalid.uri'].forEach(deny); +}); diff --git a/packages/backend/server/src/fundamentals/helpers/crypto.ts b/packages/backend/server/src/fundamentals/helpers/crypto.ts new file mode 100644 index 0000000000..fdd868cf80 --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/crypto.ts @@ -0,0 +1,115 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + createSign, + createVerify, + randomBytes, + timingSafeEqual, +} from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import { + hash as hashPassword, + verify as verifyPassword, +} from '@node-rs/argon2'; + +import { Config } from '../config'; + +const NONCE_LENGTH = 12; +const AUTH_TAG_LENGTH = 12; + +@Injectable() +export class CryptoHelper { + keyPair: { + publicKey: Buffer; + privateKey: Buffer; + sha256: { + publicKey: Buffer; + privateKey: Buffer; + }; + }; + + constructor(config: Config) { + this.keyPair = { + publicKey: Buffer.from(config.secrets.publicKey, 'utf8'), + privateKey: Buffer.from(config.secrets.privateKey, 'utf8'), + sha256: { + publicKey: this.sha256(config.secrets.publicKey), + privateKey: this.sha256(config.secrets.privateKey), + }, + }; + } + + sign(data: string) { + const sign = createSign('rsa-sha256'); + sign.update(data, 'utf-8'); + sign.end(); + return sign.sign(this.keyPair.privateKey, 'base64'); + } + + verify(data: string, signature: string) { + const verify = createVerify('rsa-sha256'); + verify.update(data, 'utf-8'); + verify.end(); + return verify.verify(this.keyPair.privateKey, signature, 'base64'); + } + + encrypt(data: string) { + const iv = this.randomBytes(); + const cipher = createCipheriv( + 'aes-256-gcm', + this.keyPair.sha256.privateKey, + iv, + { + authTagLength: AUTH_TAG_LENGTH, + } + ); + const encrypted = Buffer.concat([ + cipher.update(data, 'utf-8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); + } + + decrypt(encrypted: string) { + const buf = Buffer.from(encrypted, 'base64'); + const iv = buf.subarray(0, NONCE_LENGTH); + const authTag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + AUTH_TAG_LENGTH); + const encryptedToken = buf.subarray(NONCE_LENGTH + AUTH_TAG_LENGTH); + const decipher = createDecipheriv( + 'aes-256-gcm', + this.keyPair.sha256.privateKey, + iv, + { authTagLength: AUTH_TAG_LENGTH } + ); + decipher.setAuthTag(authTag); + const decrepted = decipher.update(encryptedToken, void 0, 'utf8'); + return decrepted + decipher.final('utf8'); + } + + encryptPassword(password: string) { + return hashPassword(password); + } + + verifyPassword(password: string, hash: string) { + return verifyPassword(hash, password); + } + + compare(lhs: string, rhs: string) { + if (lhs.length !== rhs.length) { + return false; + } + + return timingSafeEqual(Buffer.from(lhs), Buffer.from(rhs)); + } + + randomBytes(length = NONCE_LENGTH) { + return randomBytes(length); + } + + sha256(data: string) { + return createHash('sha256').update(data).digest(); + } +} diff --git a/packages/backend/server/src/fundamentals/helpers/index.ts b/packages/backend/server/src/fundamentals/helpers/index.ts new file mode 100644 index 0000000000..1d03b06af4 --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/index.ts @@ -0,0 +1,13 @@ +import { Global, Module } from '@nestjs/common'; + +import { CryptoHelper } from './crypto'; +import { URLHelper } from './url'; + +@Global() +@Module({ + providers: [URLHelper, CryptoHelper], + exports: [URLHelper, CryptoHelper], +}) +export class HelpersModule {} + +export { CryptoHelper, URLHelper }; diff --git a/packages/backend/server/src/fundamentals/helpers/url.ts b/packages/backend/server/src/fundamentals/helpers/url.ts new file mode 100644 index 0000000000..ee2a666a6c --- /dev/null +++ b/packages/backend/server/src/fundamentals/helpers/url.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { type Response } from 'express'; + +import { Config } from '../config'; + +@Injectable() +export class URLHelper { + redirectAllowHosts: string[]; + + constructor(private readonly config: Config) { + this.redirectAllowHosts = [this.config.baseUrl]; + } + + get home() { + return this.config.baseUrl; + } + + stringify(query: Record) { + return new URLSearchParams(query).toString(); + } + + link(path: string, query: Record = {}) { + const url = new URL( + this.config.baseUrl + (path.startsWith('/') ? path : '/' + path) + ); + + for (const key in query) { + url.searchParams.set(key, query[key]); + } + + return url.toString(); + } + + safeRedirect(res: Response, to: string) { + try { + const finalTo = new URL(decodeURIComponent(to), this.config.baseUrl); + + for (const host of this.redirectAllowHosts) { + const hostURL = new URL(host); + if ( + hostURL.origin === finalTo.origin && + finalTo.pathname.startsWith(hostURL.pathname) + ) { + return res.redirect(finalTo.toString().replace(/\/$/, '')); + } + } + } catch { + // just ignore invalid url + } + + // redirect to home if the url is invalid + return res.redirect(this.home); + } +} diff --git a/packages/backend/server/src/fundamentals/index.ts b/packages/backend/server/src/fundamentals/index.ts index af674c34ff..02c5515e16 100644 --- a/packages/backend/server/src/fundamentals/index.ts +++ b/packages/backend/server/src/fundamentals/index.ts @@ -14,6 +14,7 @@ export { } from './config'; export * from './error'; export { EventEmitter, type EventPayload, OnEvent } from './event'; +export { CryptoHelper, URLHelper } from './helpers'; export { MailService } from './mailer'; export { CallCounter, CallTimer, metrics } from './metrics'; export { @@ -21,7 +22,6 @@ export { GlobalExceptionFilter, OptionalModule, } from './nestjs'; -export { SessionService } from './session'; export * from './storage'; export { type StorageProvider, StorageProviderFactory } from './storage'; export { AuthThrottlerGuard, CloudThrottlerGuard, Throttle } from './throttler'; diff --git a/packages/backend/server/src/fundamentals/mailer/index.ts b/packages/backend/server/src/fundamentals/mailer/index.ts index b91a7a8fb4..d6412fa381 100644 --- a/packages/backend/server/src/fundamentals/mailer/index.ts +++ b/packages/backend/server/src/fundamentals/mailer/index.ts @@ -8,7 +8,7 @@ import { MAILER } from './mailer'; @OptionalModule({ providers: [MAILER], exports: [MAILER], - requires: ['mailer.auth.user', 'mailer.auth.pass'], + requires: ['mailer.auth.user'], }) class MailerModule {} diff --git a/packages/backend/server/src/fundamentals/mailer/mail.service.ts b/packages/backend/server/src/fundamentals/mailer/mail.service.ts index aab0620a4e..7bab15dc55 100644 --- a/packages/backend/server/src/fundamentals/mailer/mail.service.ts +++ b/packages/backend/server/src/fundamentals/mailer/mail.service.ts @@ -1,12 +1,14 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; import { Config } from '../config'; +import { URLHelper } from '../helpers'; import { MAILER_SERVICE, type MailerService, type Options } from './mailer'; import { emailTemplate } from './template'; @Injectable() export class MailService { constructor( private readonly config: Config, + private readonly url: URLHelper, @Optional() @Inject(MAILER_SERVICE) private readonly mailer?: MailerService ) {} @@ -41,7 +43,7 @@ export class MailService { } ) { // TODO: use callback url when need support desktop app - const buttonUrl = `${this.config.origin}/invite/${inviteId}`; + const buttonUrl = this.url.link(`/invite/${inviteId}`); const workspaceAvatar = invitationInfo.workspace.avatar; const content = `

${ @@ -92,7 +94,23 @@ export class MailService { }); } - async sendSignInEmail(url: string, options: Options) { + async sendSignUpMail(url: string, options: Options) { + const html = emailTemplate({ + title: 'Create AFFiNE Account', + content: + 'Click the button below to complete your account creation and sign in. This magic link will expire in 30 minutes.', + buttonContent: ' Create account and sign in', + buttonUrl: url, + }); + + return this.sendMail({ + html, + subject: 'Your AFFiNE account is waiting for you!', + ...options, + }); + } + + async sendSignInMail(url: string, options: Options) { const html = emailTemplate({ title: 'Sign in to AFFiNE', content: @@ -164,6 +182,20 @@ export class MailService { html, }); } + async sendVerifyEmail(to: string, url: string) { + const html = emailTemplate({ + title: 'Verify your email address', + content: + 'You recently requested to verify the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.', + buttonContent: 'Verify your email address', + buttonUrl: url, + }); + return this.sendMail({ + to, + subject: `Verify your email for AFFiNE`, + html, + }); + } async sendNotificationChangeEmail(to: string) { const html = emailTemplate({ title: 'Email change successful', diff --git a/packages/backend/server/src/fundamentals/mailer/mailer.ts b/packages/backend/server/src/fundamentals/mailer/mailer.ts index 1f8affac2b..eed4a4f4d9 100644 --- a/packages/backend/server/src/fundamentals/mailer/mailer.ts +++ b/packages/backend/server/src/fundamentals/mailer/mailer.ts @@ -1,4 +1,4 @@ -import { FactoryProvider } from '@nestjs/common'; +import { FactoryProvider, Logger } from '@nestjs/common'; import { createTransport, Transporter } from 'nodemailer'; import SMTPTransport from 'nodemailer/lib/smtp-transport'; @@ -15,7 +15,19 @@ export const MAILER: FactoryProvider< > = { provide: MAILER_SERVICE, useFactory: (config: Config) => { - return config.mailer ? createTransport(config.mailer) : undefined; + if (config.mailer) { + const logger = new Logger('Mailer'); + const auth = config.mailer.auth; + if (auth && auth.user && !('pass' in auth)) { + logger.warn( + 'Mailer service has not configured password, please make sure your mailer service allow empty password.' + ); + } + + return createTransport(config.mailer); + } else { + return undefined; + } }, inject: [Config], }; diff --git a/packages/backend/server/src/fundamentals/nestjs/optional-module.ts b/packages/backend/server/src/fundamentals/nestjs/optional-module.ts index e7003485b7..b53d2408f6 100644 --- a/packages/backend/server/src/fundamentals/nestjs/optional-module.ts +++ b/packages/backend/server/src/fundamentals/nestjs/optional-module.ts @@ -9,7 +9,7 @@ import { omit } from 'lodash-es'; import { Config, ConfigPaths } from '../config'; -interface OptionalModuleMetadata extends ModuleMetadata { +export interface OptionalModuleMetadata extends ModuleMetadata { /** * Only install module if given config paths are defined in AFFiNE config. */ diff --git a/packages/backend/server/src/fundamentals/session/index.ts b/packages/backend/server/src/fundamentals/session/index.ts deleted file mode 100644 index 3ee1759310..0000000000 --- a/packages/backend/server/src/fundamentals/session/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Global, Injectable, Module } from '@nestjs/common'; - -import { SessionCache } from '../cache'; - -@Injectable() -export class SessionService { - private readonly prefix = 'session:'; - public readonly sessionTtl = 30 * 60 * 1000; // 30 min - - constructor(private readonly cache: SessionCache) {} - - /** - * get session - * @param key session key - * @returns - */ - async get(key: string) { - return this.cache.get(this.prefix + key); - } - - /** - * set session - * @param key session key - * @param value session value - * @param sessionTtl session ttl (ms), default 30 min - * @returns return true if success - */ - async set(key: string, value?: any, sessionTtl = this.sessionTtl) { - return this.cache.set(this.prefix + key, value, { - ttl: sessionTtl, - }); - } - - async delete(key: string) { - return this.cache.delete(this.prefix + key); - } -} - -@Global() -@Module({ - providers: [SessionService], - exports: [SessionService], -}) -export class SessionModule {} diff --git a/packages/backend/server/src/fundamentals/utils/request.ts b/packages/backend/server/src/fundamentals/utils/request.ts index 7ee27745ce..21f4ed520f 100644 --- a/packages/backend/server/src/fundamentals/utils/request.ts +++ b/packages/backend/server/src/fundamentals/utils/request.ts @@ -1,53 +1,8 @@ import type { ArgumentsHost, ExecutionContext } from '@nestjs/common'; import type { GqlContextType } from '@nestjs/graphql'; -import { GqlArgumentsHost, GqlExecutionContext } from '@nestjs/graphql'; +import { GqlArgumentsHost } from '@nestjs/graphql'; import type { Request, Response } from 'express'; - -export function getRequestResponseFromContext(context: ExecutionContext) { - switch (context.getType()) { - case 'graphql': { - const gqlContext = GqlExecutionContext.create(context).getContext<{ - req: Request; - }>(); - return { - req: gqlContext.req, - res: gqlContext.req.res, - }; - } - case 'http': { - const http = context.switchToHttp(); - return { - req: http.getRequest(), - res: http.getResponse(), - }; - } - case 'ws': { - const ws = context.switchToWs(); - const req = ws.getClient().handshake; - - const cookies = req?.headers?.cookie; - // patch cookies to match auth guard logic - if (typeof cookies === 'string') { - req.cookies = cookies - .split(';') - .map(v => v.split('=')) - .reduce( - (acc, v) => { - acc[decodeURIComponent(v[0].trim())] = decodeURIComponent( - v[1].trim() - ); - return acc; - }, - {} as Record - ); - } - - return { req }; - } - default: - throw new Error('Unknown context type for getting request and response'); - } -} +import type { Socket } from 'socket.io'; export function getRequestResponseFromHost(host: ArgumentsHost) { switch (host.getType()) { @@ -67,11 +22,47 @@ export function getRequestResponseFromHost(host: ArgumentsHost) { res: http.getResponse(), }; } - default: - throw new Error('Unknown host type for getting request and response'); + case 'ws': { + const ws = host.switchToWs(); + const req = ws.getClient().client.conn.request as Request; + + const cookieStr = req?.headers?.cookie; + // patch cookies to match auth guard logic + if (typeof cookieStr === 'string') { + req.cookies = cookieStr.split(';').reduce( + (cookies, cookie) => { + const [key, val] = cookie.split('='); + + if (key) { + cookies[decodeURIComponent(key.trim())] = val + ? decodeURIComponent(val.trim()) + : val; + } + + return cookies; + }, + {} as Record + ); + } + + return { req }; + } + case 'rpc': { + const rpc = host.switchToRpc(); + const { req } = rpc.getContext<{ req: Request }>(); + + return { + req, + res: req.res, + }; + } } } export function getRequestFromHost(host: ArgumentsHost) { return getRequestResponseFromHost(host).req; } + +export function getRequestResponseFromContext(ctx: ExecutionContext) { + return getRequestResponseFromHost(ctx); +} diff --git a/packages/backend/server/src/global.d.ts b/packages/backend/server/src/global.d.ts index f7674001d3..ebb0fae5f1 100644 --- a/packages/backend/server/src/global.d.ts +++ b/packages/backend/server/src/global.d.ts @@ -1,6 +1,6 @@ declare namespace Express { interface Request { - user?: import('@prisma/client').User | null; + user?: import('./core/auth/current-user').CurrentUser; } } diff --git a/packages/backend/server/src/plugins/config.ts b/packages/backend/server/src/plugins/config.ts index 2e150c971c..eea08c491f 100644 --- a/packages/backend/server/src/plugins/config.ts +++ b/packages/backend/server/src/plugins/config.ts @@ -1,4 +1,5 @@ import { GCloudConfig } from './gcloud/config'; +import { OAuthConfig } from './oauth'; import { PaymentConfig } from './payment'; import { RedisOptions } from './redis'; import { R2StorageConfig, S3StorageConfig } from './storage'; @@ -10,13 +11,14 @@ declare module '../fundamentals/config' { readonly gcloud: GCloudConfig; readonly 'cloudflare-r2': R2StorageConfig; readonly 'aws-s3': S3StorageConfig; + readonly oauth: OAuthConfig; } export type AvailablePlugins = keyof PluginsConfig; interface AFFiNEConfig { readonly plugins: { - enabled: AvailablePlugins[]; + enabled: Set; use( plugin: Plugin, config?: DeepPartial diff --git a/packages/backend/server/src/plugins/gcloud/index.ts b/packages/backend/server/src/plugins/gcloud/index.ts index ed8b238c16..16a5a4494e 100644 --- a/packages/backend/server/src/plugins/gcloud/index.ts +++ b/packages/backend/server/src/plugins/gcloud/index.ts @@ -1,10 +1,11 @@ import { Global } from '@nestjs/common'; -import { OptionalModule } from '../../fundamentals'; +import { Plugin } from '../registry'; import { GCloudMetrics } from './metrics'; @Global() -@OptionalModule({ +@Plugin({ + name: 'gcloud', imports: [GCloudMetrics], }) export class GCloudModule {} diff --git a/packages/backend/server/src/plugins/index.ts b/packages/backend/server/src/plugins/index.ts index 9780e7322a..42ea147ad3 100644 --- a/packages/backend/server/src/plugins/index.ts +++ b/packages/backend/server/src/plugins/index.ts @@ -1,13 +1,7 @@ -import type { AvailablePlugins } from '../fundamentals/config'; -import { GCloudModule } from './gcloud'; -import { PaymentModule } from './payment'; -import { RedisModule } from './redis'; -import { AwsS3Module, CloudflareR2Module } from './storage'; +import './gcloud'; +import './oauth'; +import './payment'; +import './redis'; +import './storage'; -export const pluginsMap = new Map([ - ['payment', PaymentModule], - ['redis', RedisModule], - ['gcloud', GCloudModule], - ['cloudflare-r2', CloudflareR2Module], - ['aws-s3', AwsS3Module], -]); +export { REGISTERED_PLUGINS } from './registry'; diff --git a/packages/backend/server/src/plugins/oauth/controller.ts b/packages/backend/server/src/plugins/oauth/controller.ts new file mode 100644 index 0000000000..8be8d69a5a --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/controller.ts @@ -0,0 +1,247 @@ +import { + BadRequestException, + Controller, + Get, + Query, + Req, + Res, +} from '@nestjs/common'; +import { ConnectedAccount, PrismaClient } from '@prisma/client'; +import type { Request, Response } from 'express'; + +import { AuthService, Public } from '../../core/auth'; +import { UserService } from '../../core/user'; +import { URLHelper } from '../../fundamentals'; +import { OAuthAccount, Tokens } from './providers/def'; +import { OAuthProviderFactory } from './register'; +import { OAuthService } from './service'; +import { OAuthProviderName } from './types'; + +@Controller('/oauth') +export class OAuthController { + constructor( + private readonly auth: AuthService, + private readonly oauth: OAuthService, + private readonly user: UserService, + private readonly providerFactory: OAuthProviderFactory, + private readonly url: URLHelper, + private readonly db: PrismaClient + ) {} + + @Public() + @Get('/login') + async login( + @Res() res: Response, + @Query('provider') unknownProviderName: string, + @Query('redirect_uri') redirectUri?: string + ) { + // @ts-expect-error safe + const providerName = OAuthProviderName[unknownProviderName]; + const provider = this.providerFactory.get(providerName); + + if (!provider) { + throw new BadRequestException('Invalid provider'); + } + + const state = await this.oauth.saveOAuthState({ + redirectUri: redirectUri ?? this.url.home, + provider: providerName, + }); + + return res.redirect(provider.getAuthUrl(state)); + } + + @Public() + @Get('/callback') + async callback( + @Req() req: Request, + @Res() res: Response, + @Query('code') code?: string, + @Query('state') stateStr?: string + ) { + if (!code) { + throw new BadRequestException('Missing query parameter `code`'); + } + + if (!stateStr) { + throw new BadRequestException('Invalid callback state parameter'); + } + + const state = await this.oauth.getOAuthState(stateStr); + + if (!state) { + throw new BadRequestException('OAuth state expired, please try again.'); + } + + if (!state.provider) { + throw new BadRequestException( + 'Missing callback state parameter `provider`' + ); + } + + const provider = this.providerFactory.get(state.provider); + + if (!provider) { + throw new BadRequestException('Invalid provider'); + } + + const tokens = await provider.getToken(code); + const externAccount = await provider.getUser(tokens.accessToken); + const user = req.user; + + try { + if (!user) { + // if user not found, login + const user = await this.loginFromOauth( + state.provider, + externAccount, + tokens + ); + const session = await this.auth.createUserSession( + user, + req.cookies[AuthService.sessionCookieName] + ); + res.cookie(AuthService.sessionCookieName, session.sessionId, { + expires: session.expiresAt ?? void 0, // expiredAt is `string | null` + ...this.auth.cookieOptions, + }); + } else { + // if user is found, connect the account to this user + await this.connectAccountFromOauth( + user, + state.provider, + externAccount, + tokens + ); + } + } catch (e: any) { + return res.redirect( + this.url.link('/signIn', { + redirect_uri: state.redirectUri, + error: e.message, + }) + ); + } + + this.url.safeRedirect(res, state.redirectUri); + } + + private async loginFromOauth( + provider: OAuthProviderName, + externalAccount: OAuthAccount, + tokens: Tokens + ) { + const connectedUser = await this.db.connectedAccount.findFirst({ + where: { + provider, + providerAccountId: externalAccount.id, + }, + include: { + user: true, + }, + }); + + if (connectedUser) { + // already connected + await this.updateConnectedAccount(connectedUser, tokens); + + return connectedUser.user; + } + + let user = await this.user.findUserByEmail(externalAccount.email); + + if (user) { + // we can't directly connect the external account with given email in sign in scenario for safety concern. + // let user manually connect in account sessions instead. + if (user.registered) { + throw new BadRequestException( + 'The account with provided email is not register in the same way.' + ); + } + + await this.user.fulfillUser(externalAccount.email, { + emailVerifiedAt: new Date(), + registered: true, + }); + await this.db.connectedAccount.create({ + data: { + userId: user.id, + provider, + providerAccountId: externalAccount.id, + ...tokens, + }, + }); + + return user; + } else { + user = await this.createUserWithConnectedAccount( + provider, + externalAccount, + tokens + ); + } + + return user; + } + + updateConnectedAccount(connectedUser: ConnectedAccount, tokens: Tokens) { + return this.db.connectedAccount.update({ + where: { + id: connectedUser.id, + }, + data: tokens, + }); + } + + async createUserWithConnectedAccount( + provider: OAuthProviderName, + externalAccount: OAuthAccount, + tokens: Tokens + ) { + return this.user.createUser({ + email: externalAccount.email, + name: externalAccount.email.split('@')[0], + avatarUrl: externalAccount.avatarUrl, + emailVerifiedAt: new Date(), + connectedAccounts: { + create: { + provider, + providerAccountId: externalAccount.id, + ...tokens, + }, + }, + }); + } + + private async connectAccountFromOauth( + user: { id: string }, + provider: OAuthProviderName, + externalAccount: OAuthAccount, + tokens: Tokens + ) { + const connectedUser = await this.db.connectedAccount.findFirst({ + where: { + provider, + providerAccountId: externalAccount.id, + }, + }); + + if (connectedUser) { + if (connectedUser.id !== user.id) { + throw new BadRequestException( + 'The third-party account has already been connected to another user.' + ); + } + } else { + await this.db.connectedAccount.create({ + data: { + userId: user.id, + provider, + providerAccountId: externalAccount.id, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }, + }); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/index.ts b/packages/backend/server/src/plugins/oauth/index.ts new file mode 100644 index 0000000000..0b14d1d984 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/index.ts @@ -0,0 +1,25 @@ +import { AuthModule } from '../../core/auth'; +import { ServerFeature } from '../../core/config'; +import { UserModule } from '../../core/user'; +import { Plugin } from '../registry'; +import { OAuthController } from './controller'; +import { OAuthProviders } from './providers'; +import { OAuthProviderFactory } from './register'; +import { OAuthResolver } from './resolver'; +import { OAuthService } from './service'; + +@Plugin({ + name: 'oauth', + imports: [AuthModule, UserModule], + providers: [ + OAuthProviderFactory, + OAuthService, + OAuthResolver, + ...OAuthProviders, + ], + controllers: [OAuthController], + contributesTo: ServerFeature.OAuth, + if: config => !!config.plugins.oauth, +}) +export class OAuthModule {} +export type { OAuthConfig } from './types'; diff --git a/packages/backend/server/src/plugins/oauth/providers/def.ts b/packages/backend/server/src/plugins/oauth/providers/def.ts new file mode 100644 index 0000000000..7e7913cdaf --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/def.ts @@ -0,0 +1,21 @@ +import { OAuthProviderName } from '../types'; + +export interface OAuthAccount { + id: string; + email: string; + avatarUrl?: string; +} + +export interface Tokens { + accessToken: string; + scope?: string; + refreshToken?: string; + expiresAt?: Date; +} + +export abstract class OAuthProvider { + abstract provider: OAuthProviderName; + abstract getAuthUrl(state?: string): string; + abstract getToken(code: string): Promise; + abstract getUser(token: string): Promise; +} diff --git a/packages/backend/server/src/plugins/oauth/providers/github.ts b/packages/backend/server/src/plugins/oauth/providers/github.ts new file mode 100644 index 0000000000..50227539a7 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/github.ts @@ -0,0 +1,113 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; + +import { Config, URLHelper } from '../../../fundamentals'; +import { AutoRegisteredOAuthProvider } from '../register'; +import { OAuthProviderName } from '../types'; + +interface AuthTokenResponse { + access_token: string; + scope: string; + token_type: string; +} + +export interface UserInfo { + login: string; + email: string; + avatar_url: string; + name: string; +} + +@Injectable() +export class GithubOAuthProvider extends AutoRegisteredOAuthProvider { + provider = OAuthProviderName.GitHub; + + constructor( + protected readonly AFFiNEConfig: Config, + private readonly url: URLHelper + ) { + super(); + } + + getAuthUrl(state: string) { + return `https://github.com/login/oauth/authorize?${this.url.stringify({ + client_id: this.config.clientId, + redirect_uri: this.url.link('/oauth/callback'), + scope: 'user', + ...this.config.args, + state, + })}`; + } + + async getToken(code: string) { + try { + const response = await fetch( + 'https://github.com/login/oauth/access_token', + { + method: 'POST', + body: this.url.stringify({ + code, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + redirect_uri: this.url.link('/oauth/callback'), + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + if (response.ok) { + const ghToken = (await response.json()) as AuthTokenResponse; + + return { + accessToken: ghToken.access_token, + scope: ghToken.scope, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + }, ${JSON.stringify(await response.json())}` + ); + } + } catch (e) { + throw new HttpException( + `Failed to get access_token, err: ${(e as Error).message}`, + HttpStatus.BAD_REQUEST + ); + } + } + + async getUser(token: string) { + try { + const response = await fetch('https://api.github.com/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const user = (await response.json()) as UserInfo; + + return { + id: user.login, + avatarUrl: user.avatar_url, + email: user.email, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + } ${await response.text()}` + ); + } + } catch (e) { + throw new HttpException( + `Failed to get user information, err: ${(e as Error).stack}`, + HttpStatus.BAD_REQUEST + ); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/providers/google.ts b/packages/backend/server/src/plugins/oauth/providers/google.ts new file mode 100644 index 0000000000..fb22bd36f0 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/google.ts @@ -0,0 +1,121 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; + +import { Config, URLHelper } from '../../../fundamentals'; +import { AutoRegisteredOAuthProvider } from '../register'; +import { OAuthProviderName } from '../types'; + +interface GoogleOAuthTokenResponse { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +} + +export interface UserInfo { + id: string; + email: string; + picture: string; + name: string; +} + +@Injectable() +export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider { + override provider = OAuthProviderName.Google; + + constructor( + protected readonly AFFiNEConfig: Config, + private readonly url: URLHelper + ) { + super(); + } + + getAuthUrl(state: string) { + return `https://accounts.google.com/o/oauth2/v2/auth?${this.url.stringify({ + client_id: this.config.clientId, + redirect_uri: this.url.link('/oauth/callback'), + response_type: 'code', + scope: 'openid email profile', + promot: 'select_account', + access_type: 'offline', + ...this.config.args, + state, + })}`; + } + + async getToken(code: string) { + try { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + body: this.url.stringify({ + code, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + redirect_uri: this.url.link('/oauth/callback'), + grant_type: 'authorization_code', + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + if (response.ok) { + const ghToken = (await response.json()) as GoogleOAuthTokenResponse; + + return { + accessToken: ghToken.access_token, + refreshToken: ghToken.refresh_token, + expiresAt: new Date(Date.now() + ghToken.expires_in * 1000), + scope: ghToken.scope, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + }, ${JSON.stringify(await response.json())}` + ); + } + } catch (e) { + throw new HttpException( + `Failed to get access_token, err: ${(e as Error).message}`, + HttpStatus.BAD_REQUEST + ); + } + } + + async getUser(token: string) { + try { + const response = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (response.ok) { + const user = (await response.json()) as UserInfo; + + return { + id: user.id, + avatarUrl: user.picture, + email: user.email, + }; + } else { + throw new Error( + `Server responded with non-success code ${ + response.status + } ${await response.text()}` + ); + } + } catch (e) { + throw new HttpException( + `Failed to get user information, err: ${(e as Error).stack}`, + HttpStatus.BAD_REQUEST + ); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/providers/index.ts b/packages/backend/server/src/plugins/oauth/providers/index.ts new file mode 100644 index 0000000000..7af95d12d8 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/index.ts @@ -0,0 +1,4 @@ +import { GithubOAuthProvider } from './github'; +import { GoogleOAuthProvider } from './google'; + +export const OAuthProviders = [GoogleOAuthProvider, GithubOAuthProvider]; diff --git a/packages/backend/server/src/plugins/oauth/register.ts b/packages/backend/server/src/plugins/oauth/register.ts new file mode 100644 index 0000000000..d6c53c57d2 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/register.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; + +import { Config } from '../../fundamentals'; +import { OAuthProvider } from './providers/def'; +import { OAuthProviderName } from './types'; + +const PROVIDERS: Map = new Map(); + +export function registerOAuthProvider( + name: OAuthProviderName, + provider: OAuthProvider +) { + PROVIDERS.set(name, provider); +} + +@Injectable() +export class OAuthProviderFactory { + get providers() { + return PROVIDERS.keys(); + } + + get(name: OAuthProviderName): OAuthProvider | undefined { + return PROVIDERS.get(name); + } +} + +export abstract class AutoRegisteredOAuthProvider + extends OAuthProvider + implements OnModuleInit +{ + protected abstract AFFiNEConfig: Config; + + get optionalConfig() { + return this.AFFiNEConfig.plugins.oauth?.providers?.[this.provider]; + } + + get config() { + const config = this.optionalConfig; + + if (!config) { + throw new Error( + `OAuthProvider Config should not be used before registered` + ); + } + + return config; + } + + onModuleInit() { + const config = this.optionalConfig; + if (config && config.clientId && config.clientSecret) { + registerOAuthProvider(this.provider, this); + new Logger(`OAuthProvider:${this.provider}`).log( + 'OAuth provider registered.' + ); + } + } +} diff --git a/packages/backend/server/src/plugins/oauth/resolver.ts b/packages/backend/server/src/plugins/oauth/resolver.ts new file mode 100644 index 0000000000..467cc90360 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/resolver.ts @@ -0,0 +1,17 @@ +import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql'; + +import { ServerConfigType } from '../../core/config'; +import { OAuthProviderFactory } from './register'; +import { OAuthProviderName } from './types'; + +registerEnumType(OAuthProviderName, { name: 'OAuthProviderType' }); + +@Resolver(() => ServerConfigType) +export class OAuthResolver { + constructor(private readonly factory: OAuthProviderFactory) {} + + @ResolveField(() => [OAuthProviderName]) + oauthProviders() { + return this.factory.providers; + } +} diff --git a/packages/backend/server/src/plugins/oauth/service.ts b/packages/backend/server/src/plugins/oauth/service.ts new file mode 100644 index 0000000000..d05dc623df --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/service.ts @@ -0,0 +1,39 @@ +import { randomUUID } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; + +import { SessionCache } from '../../fundamentals'; +import { OAuthProviderFactory } from './register'; +import { OAuthProviderName } from './types'; + +const OAUTH_STATE_KEY = 'OAUTH_STATE'; + +interface OAuthState { + redirectUri: string; + provider: OAuthProviderName; +} + +@Injectable() +export class OAuthService { + constructor( + private readonly providerFactory: OAuthProviderFactory, + private readonly cache: SessionCache + ) {} + + async saveOAuthState(state: OAuthState) { + const token = randomUUID(); + await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, { + ttl: 3600 * 3 * 1000 /* 3 hours */, + }); + + return token; + } + + async getOAuthState(token: string) { + return this.cache.get(`${OAUTH_STATE_KEY}:${token}`); + } + + availableOAuthProviders() { + return this.providerFactory.providers; + } +} diff --git a/packages/backend/server/src/plugins/oauth/types.ts b/packages/backend/server/src/plugins/oauth/types.ts new file mode 100644 index 0000000000..6d66a264c3 --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/types.ts @@ -0,0 +1,15 @@ +export interface OAuthProviderConfig { + clientId: string; + clientSecret: string; + args?: Record; +} + +export enum OAuthProviderName { + Google = 'google', + GitHub = 'github', +} + +export interface OAuthConfig { + enabled: boolean; + providers: Partial<{ [key in OAuthProviderName]: OAuthProviderConfig }>; +} diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts index 9a4e33578f..975582a879 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -1,13 +1,14 @@ import { ServerFeature } from '../../core/config'; import { FeatureModule } from '../../core/features'; -import { OptionalModule } from '../../fundamentals'; +import { Plugin } from '../registry'; import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; import { ScheduleManager } from './schedule'; import { SubscriptionService } from './service'; import { StripeProvider } from './stripe'; import { StripeWebhook } from './webhook'; -@OptionalModule({ +@Plugin({ + name: 'payment', imports: [FeatureModule], providers: [ ScheduleManager, diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index e171b7a998..e3f5462d99 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -21,8 +21,8 @@ import type { User, UserInvoice, UserSubscription } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import { groupBy } from 'lodash-es'; -import { Auth, CurrentUser, Public } from '../../core/auth'; -import { UserType } from '../../core/users'; +import { CurrentUser, Public } from '../../core/auth'; +import { UserType } from '../../core/user'; import { Config } from '../../fundamentals'; import { decodeLookupKey, SubscriptionService } from './service'; import { @@ -155,7 +155,6 @@ class CreateCheckoutSessionInput { idempotencyKey!: string; } -@Auth() @Resolver(() => UserSubscriptionType) export class SubscriptionResolver { constructor( @@ -217,7 +216,7 @@ export class SubscriptionResolver { description: 'Create a subscription checkout link of stripe', }) async checkout( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) recurring: SubscriptionRecurring, @Args('idempotencyKey') idempotencyKey: string @@ -241,7 +240,7 @@ export class SubscriptionResolver { description: 'Create a subscription checkout link of stripe', }) async createCheckoutSession( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args({ name: 'input', type: () => CreateCheckoutSessionInput }) input: CreateCheckoutSessionInput ) { @@ -265,13 +264,13 @@ export class SubscriptionResolver { @Mutation(() => String, { description: 'Create a stripe customer portal to manage payment methods', }) - async createCustomerPortal(@CurrentUser() user: User) { + async createCustomerPortal(@CurrentUser() user: CurrentUser) { return this.service.createCustomerPortal(user.id); } @Mutation(() => UserSubscriptionType) async cancelSubscription( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args('idempotencyKey') idempotencyKey: string ) { return this.service.cancelSubscription(idempotencyKey, user.id); @@ -279,7 +278,7 @@ export class SubscriptionResolver { @Mutation(() => UserSubscriptionType) async resumeSubscription( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args('idempotencyKey') idempotencyKey: string ) { return this.service.resumeCanceledSubscription(idempotencyKey, user.id); @@ -287,7 +286,7 @@ export class SubscriptionResolver { @Mutation(() => UserSubscriptionType) async updateSubscriptionRecurring( - @CurrentUser() user: User, + @CurrentUser() user: CurrentUser, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) recurring: SubscriptionRecurring, @Args('idempotencyKey') idempotencyKey: string diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index 30a1ffb3f8..a23fe51c8d 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -10,6 +10,7 @@ import type { import { PrismaClient } from '@prisma/client'; import Stripe from 'stripe'; +import { CurrentUser } from '../../core/auth'; import { FeatureManagementService } from '../../core/features'; import { EventEmitter } from '../../fundamentals'; import { ScheduleManager } from './schedule'; @@ -75,7 +76,7 @@ export class SubscriptionService { redirectUrl, idempotencyKey, }: { - user: User; + user: CurrentUser; recurring: SubscriptionRecurring; plan: SubscriptionPlan; promotionCode?: string | null; @@ -549,7 +550,7 @@ export class SubscriptionService { private async getOrCreateCustomer( idempotencyKey: string, - user: User + user: CurrentUser ): Promise { const customer = await this.db.userStripeCustomer.findUnique({ where: { @@ -649,7 +650,7 @@ export class SubscriptionService { } private async getAvailableCoupon( - user: User, + user: CurrentUser, couponType: CouponType ): Promise { const earlyAccess = await this.features.isEarlyAccessUser(user.email); diff --git a/packages/backend/server/src/plugins/redis/index.ts b/packages/backend/server/src/plugins/redis/index.ts index 46b44fe7fd..58d82c4642 100644 --- a/packages/backend/server/src/plugins/redis/index.ts +++ b/packages/backend/server/src/plugins/redis/index.ts @@ -2,9 +2,10 @@ import { Global, Provider, Type } from '@nestjs/common'; import { Redis, type RedisOptions } from 'ioredis'; import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; -import { Cache, OptionalModule, SessionCache } from '../../fundamentals'; +import { Cache, SessionCache } from '../../fundamentals'; import { ThrottlerStorage } from '../../fundamentals/throttler'; import { SocketIoAdapterImpl } from '../../fundamentals/websocket'; +import { Plugin } from '../registry'; import { RedisCache } from './cache'; import { CacheRedis, @@ -47,7 +48,8 @@ const socketIoRedisAdapterProvider: Provider = { }; @Global() -@OptionalModule({ +@Plugin({ + name: 'redis', providers: [CacheRedis, SessionRedis, ThrottlerRedis, SocketIoRedis], overrides: [ cacheProvider, diff --git a/packages/backend/server/src/plugins/registry.ts b/packages/backend/server/src/plugins/registry.ts new file mode 100644 index 0000000000..2eea3728af --- /dev/null +++ b/packages/backend/server/src/plugins/registry.ts @@ -0,0 +1,22 @@ +import { omit } from 'lodash-es'; + +import { AvailablePlugins } from '../fundamentals/config'; +import { OptionalModule, OptionalModuleMetadata } from '../fundamentals/nestjs'; + +export const REGISTERED_PLUGINS = new Map(); + +function register(plugin: AvailablePlugins, module: AFFiNEModule) { + REGISTERED_PLUGINS.set(plugin, module); +} + +interface PluginModuleMetadata extends OptionalModuleMetadata { + name: AvailablePlugins; +} + +export const Plugin = (options: PluginModuleMetadata) => { + return (target: any) => { + register(options.name, target); + + return OptionalModule(omit(options, 'name'))(target); + }; +}; diff --git a/packages/backend/server/src/plugins/storage/index.ts b/packages/backend/server/src/plugins/storage/index.ts index 914b68a5b0..7128f79126 100644 --- a/packages/backend/server/src/plugins/storage/index.ts +++ b/packages/backend/server/src/plugins/storage/index.ts @@ -1,5 +1,5 @@ -import { OptionalModule } from '../../fundamentals'; import { registerStorageProvider } from '../../fundamentals/storage'; +import { Plugin } from '../registry'; import { R2StorageProvider } from './providers/r2'; import { S3StorageProvider } from './providers/s3'; @@ -18,7 +18,8 @@ registerStorageProvider('aws-s3', (config, bucket) => { return new S3StorageProvider(config.plugins['aws-s3'], bucket); }); -@OptionalModule({ +@Plugin({ + name: 'cloudflare-r2', requires: [ 'plugins.cloudflare-r2.accountId', 'plugins.cloudflare-r2.credentials.accessKeyId', @@ -28,7 +29,8 @@ registerStorageProvider('aws-s3', (config, bucket) => { }) export class CloudflareR2Module {} -@OptionalModule({ +@Plugin({ + name: 'aws-s3', requires: [ 'plugins.aws-s3.credentials.accessKeyId', 'plugins.aws-s3.credentials.secretAccessKey', diff --git a/packages/backend/server/src/prelude.ts b/packages/backend/server/src/prelude.ts index 7f818774c7..39312a36e3 100644 --- a/packages/backend/server/src/prelude.ts +++ b/packages/backend/server/src/prelude.ts @@ -2,7 +2,7 @@ import 'reflect-metadata'; import { cpSync } from 'node:fs'; import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { config } from 'dotenv'; import { omit } from 'lodash-es'; @@ -21,7 +21,7 @@ async function loadRemote(remoteDir: string, file: string) { }); } - await import(filePath); + await import(pathToFileURL(filePath).href); } async function load() { diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 14a1d27036..2d18df9e27 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -67,14 +67,14 @@ type InviteUserType { """User avatar url""" avatarUrl: String - """User created date""" - createdAt: DateTime + """User email verified""" + createdAt: DateTime @deprecated(reason: "useless") """User email""" email: String """User email verified""" - emailVerified: DateTime + emailVerified: Boolean """User password has been set""" hasPassword: Boolean @@ -111,7 +111,7 @@ type Mutation { addToEarlyAccess(email: String!): Int! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! cancelSubscription(idempotencyKey: String!): UserSubscription! - changeEmail(token: String!): UserType! + changeEmail(email: String!, token: String!): UserType! changePassword(newPassword: String!, token: String!): UserType! """Create a subscription checkout link of stripe""" @@ -141,15 +141,17 @@ type Mutation { revoke(userId: String!, workspaceId: String!): Boolean! revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! - sendChangeEmail(callbackUrl: String!, email: String!): Boolean! - sendChangePasswordEmail(callbackUrl: String!, email: String!): Boolean! - sendSetPasswordEmail(callbackUrl: String!, email: String!): Boolean! + sendChangeEmail(callbackUrl: String!, email: String): Boolean! + sendChangePasswordEmail(callbackUrl: String!, email: String): Boolean! + sendSetPasswordEmail(callbackUrl: String!, email: String): Boolean! sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean! + sendVerifyEmail(callbackUrl: String!): Boolean! setBlob(blob: Upload!, workspaceId: String!): String! setWorkspaceExperimentalFeature(enable: Boolean!, feature: FeatureType!, workspaceId: String!): Boolean! sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publicPage") signIn(email: String!, password: String!): UserType! signUp(email: String!, name: String!, password: String!): UserType! + updateProfile(input: UpdateUserInput!): UserType! updateSubscriptionRecurring(idempotencyKey: String!, recurring: SubscriptionRecurring!): UserSubscription! """Update workspace""" @@ -157,6 +159,12 @@ type Mutation { """Upload user avatar""" uploadAvatar(avatar: Upload!): UserType! + verifyEmail(token: String!): Boolean! +} + +enum OAuthProviderType { + GitHub + Google } """User permission in workspace""" @@ -239,6 +247,7 @@ type ServerConfigType { """server identical name could be shown as badge on user interface""" name: String! + oauthProviders: [OAuthProviderType!]! """server type""" type: ServerDeploymentType! @@ -253,6 +262,7 @@ enum ServerDeploymentType { } enum ServerFeature { + OAuth Payment } @@ -288,10 +298,9 @@ enum SubscriptionStatus { Unpaid } -type TokenType { - refresh: String! - sessionToken: String - token: String! +input UpdateUserInput { + """User name""" + name: String } input UpdateWorkspaceInput { @@ -356,14 +365,17 @@ type UserType { """User avatar url""" avatarUrl: String - """User created date""" - createdAt: DateTime + """User email verified""" + createdAt: DateTime @deprecated(reason: "useless") """User email""" email: String! """User email verified""" - emailVerified: DateTime + emailVerified: Boolean! + + """Enabled features of a user""" + features: [FeatureType!]! """User password has been set""" hasPassword: Boolean @@ -377,7 +389,7 @@ type UserType { name: String! quota: UserQuota subscription: UserSubscription - token: TokenType! + token: tokenType! @deprecated(reason: "use [/api/auth/authorize]") } type WorkspaceBlobSizes { @@ -432,4 +444,10 @@ type WorkspaceType { """Shared pages of workspace""" sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") +} + +type tokenType { + refresh: String! + sessionToken: String + token: String! } \ No newline at end of file diff --git a/packages/backend/server/tests/app.e2e.ts b/packages/backend/server/tests/app.e2e.ts index 6fd112927c..111d661324 100644 --- a/packages/backend/server/tests/app.e2e.ts +++ b/packages/backend/server/tests/app.e2e.ts @@ -1,16 +1,8 @@ -import { ok } from 'node:assert'; -import { randomUUID } from 'node:crypto'; - -import { Transformer } from '@napi-rs/image'; import type { INestApplication } from '@nestjs/common'; -import { hashSync } from '@node-rs/argon2'; -import { PrismaClient, type User } from '@prisma/client'; import ava, { type TestFn } from 'ava'; -import type { Express } from 'express'; import request from 'supertest'; import { AppModule } from '../src/app.module'; -import { FeatureManagementService } from '../src/core/features'; import { createTestingApp } from './utils'; const gql = '/graphql'; @@ -19,43 +11,9 @@ const test = ava as TestFn<{ app: INestApplication; }>; -class FakePrisma { - fakeUser: User = { - id: randomUUID(), - name: 'Alex Yang', - avatarUrl: '', - email: 'alex.yang@example.org', - password: hashSync('123456'), - emailVerified: new Date(), - createdAt: new Date(), - }; - get user() { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const prisma = this; - return { - async findFirst() { - return prisma.fakeUser; - }, - async findUnique() { - return this.findFirst(); - }, - async update() { - return this.findFirst(); - }, - }; - } -} - test.beforeEach(async t => { const { app } = await createTestingApp({ imports: [AppModule], - tapModule(builder) { - builder - .overrideProvider(PrismaClient) - .useClass(FakePrisma) - .overrideProvider(FeatureManagementService) - .useValue({ canEarlyAccess: () => true }); - }, }); t.context.app = app; @@ -66,7 +24,6 @@ test.afterEach.always(async t => { }); test('should init app', async t => { - t.is(typeof t.context.app, 'object'); await request(t.context.app.getHttpServer()) .post(gql) .send({ @@ -78,130 +35,22 @@ test('should init app', async t => { }) .expect(400); - const { token } = await createToken(t.context.app); - - await request(t.context.app.getHttpServer()) + const response = await request(t.context.app.getHttpServer()) .post(gql) - .auth(token, { type: 'bearer' }) .send({ - query: ` - query { - __typename - } - `, - }) - .expect(200) - .expect(res => { - t.is(res.body.data.__typename, 'Query'); - }); -}); - -test('should find default user', async t => { - const { token } = await createToken(t.context.app); - await request(t.context.app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .send({ - query: ` - query { - user(email: "alex.yang@example.org") { - ... on UserType { - email - } - ... on LimitedUserType { - email - } + query: `query { + serverConfig { + name + version + type + features } - } - `, + }`, }) - .expect(200) - .expect(res => { - t.is(res.body.data.user.email, 'alex.yang@example.org'); - }); + .expect(200); + + const config = response.body.data.serverConfig; + + t.is(config.type, 'Affine'); + t.true(Array.isArray(config.features)); }); - -test('should be able to upload avatar and remove it', async t => { - const { token, id } = await createToken(t.context.app); - const png = await Transformer.fromRgbaPixels( - Buffer.alloc(400 * 400 * 4).fill(255), - 400, - 400 - ).png(); - - await request(t.context.app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .field( - 'operations', - JSON.stringify({ - name: 'uploadAvatar', - query: `mutation uploadAvatar($avatar: Upload!) { - uploadAvatar(avatar: $avatar) { - id - name - avatarUrl - email - } - } - `, - variables: { id, avatar: null }, - }) - ) - .field('map', JSON.stringify({ '0': ['variables.avatar'] })) - .attach('0', png, 'avatar.png') - .expect(200) - .expect(res => { - t.is(res.body.data.uploadAvatar.id, id); - }); - - await request(t.context.app.getHttpServer()) - .post(gql) - .auth(token, { type: 'bearer' }) - .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) - .send({ - query: ` - mutation removeAvatar { - removeAvatar { - success - } - } - `, - }) - .expect(200) - .expect(res => { - t.is(res.body.data.removeAvatar.success, true); - }); -}); - -async function createToken(app: INestApplication): Promise<{ - id: string; - token: string; -}> { - let token; - let id; - await request(app.getHttpServer()) - .post(gql) - .send({ - query: ` - mutation { - signIn(email: "alex.yang@example.org", password: "123456") { - id - token { - token - } - } - } - `, - }) - .expect(200) - .expect(res => { - id = res.body.data.signIn.id; - ok( - typeof res.body.data.signIn.token.token === 'string', - 'res.body.data.signIn.token.token is not a string' - ); - token = res.body.data.signIn.token.token; - }); - return { token: token!, id: id! }; -} diff --git a/packages/backend/server/tests/auth.e2e.ts b/packages/backend/server/tests/auth.e2e.ts index 4300928b6a..a6b4db7c29 100644 --- a/packages/backend/server/tests/auth.e2e.ts +++ b/packages/backend/server/tests/auth.e2e.ts @@ -39,7 +39,7 @@ test('change email', async t => { if (mail.hasConfigured()) { const u1Email = 'u1@affine.pro'; const u2Email = 'u2@affine.pro'; - const tokenRegex = /token=3D([^"&\s]+)/; + const tokenRegex = /token=3D([^"&]+)/; const u1 = await signUp(app, 'u1', u1Email, '1'); @@ -57,7 +57,7 @@ test('change email', async t => { const changeTokenMatch = changeEmailContent.Content.Body.match(tokenRegex); const changeEmailToken = changeTokenMatch - ? decodeURIComponent(changeTokenMatch[1].replace(/=3D/g, '=')) + ? decodeURIComponent(changeTokenMatch[1].replace(/=\r\n/, '')) : null; t.not( @@ -85,7 +85,7 @@ test('change email', async t => { const verifyTokenMatch = verifyEmailContent.Content.Body.match(tokenRegex); const verifyEmailToken = verifyTokenMatch - ? decodeURIComponent(verifyTokenMatch[1].replace(/=3D/g, '=')) + ? decodeURIComponent(verifyTokenMatch[1].replace(/=\r\n/, '')) : null; t.not( @@ -94,7 +94,7 @@ test('change email', async t => { 'fail to get verify change email token from email content' ); - await changeEmail(app, u1.token.token, verifyEmailToken as string); + await changeEmail(app, u1.token.token, verifyEmailToken as string, u2Email); const afterNotificationMailCount = await getCurrentMailMessageCount(); diff --git a/packages/backend/server/tests/auth.spec.ts b/packages/backend/server/tests/auth.spec.ts deleted file mode 100644 index f5719ba5d2..0000000000 --- a/packages/backend/server/tests/auth.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -/// -import { TestingModule } from '@nestjs/testing'; -import test from 'ava'; - -import { AuthResolver } from '../src/core/auth/resolver'; -import { AuthService } from '../src/core/auth/service'; -import { ConfigModule } from '../src/fundamentals/config'; -import { - mintChallengeResponse, - verifyChallengeResponse, -} from '../src/fundamentals/storage'; -import { createTestingModule } from './utils'; - -let authService: AuthService; -let authResolver: AuthResolver; -let module: TestingModule; - -test.beforeEach(async () => { - module = await createTestingModule({ - imports: [ - ConfigModule.forRoot({ - auth: { - accessTokenExpiresIn: 1, - refreshTokenExpiresIn: 1, - leeway: 1, - }, - host: 'example.org', - https: true, - }), - ], - }); - - authService = module.get(AuthService); - authResolver = module.get(AuthResolver); -}); - -test.afterEach.always(async () => { - await module.close(); -}); - -test('should be able to register and signIn', async t => { - await authService.signUp('Alex Yang', 'alexyang@example.org', '123456'); - await authService.signIn('alexyang@example.org', '123456'); - t.pass(); -}); - -test('should be able to verify', async t => { - await authService.signUp('Alex Yang', 'alexyang@example.org', '123456'); - await authService.signIn('alexyang@example.org', '123456'); - const date = new Date(); - - const user = { - id: '1', - name: 'Alex Yang', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - { - const token = await authService.sign(user); - const claim = await authService.verify(token); - t.is(claim.id, '1'); - t.is(claim.name, 'Alex Yang'); - t.is(claim.email, 'alexyang@example.org'); - t.is(claim.emailVerified?.toISOString(), date.toISOString()); - t.is(claim.createdAt.toISOString(), date.toISOString()); - } - { - const token = await authService.refresh(user); - const claim = await authService.verify(token); - t.is(claim.id, '1'); - t.is(claim.name, 'Alex Yang'); - t.is(claim.email, 'alexyang@example.org'); - t.is(claim.emailVerified?.toISOString(), date.toISOString()); - t.is(claim.createdAt.toISOString(), date.toISOString()); - } -}); - -test('should not be able to return token if user is invalid', async t => { - const date = new Date(); - const user = { - id: '1', - name: 'Alex Yang', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - const anotherUser = { - id: '2', - name: 'Alex Yang 2', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - await t.throwsAsync( - authResolver.token( - { - req: { - headers: { - referer: 'https://example.org', - host: 'example.org', - }, - } as any, - }, - user, - anotherUser - ), - { - message: 'Invalid user', - } - ); -}); - -test('should not return sessionToken if request headers is invalid', async t => { - const date = new Date(); - const user = { - id: '1', - name: 'Alex Yang', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - const result = await authResolver.token( - { - req: { - headers: {}, - } as any, - }, - user, - user - ); - t.is(result.sessionToken, undefined); -}); - -test('should return valid sessionToken if request headers valid', async t => { - const date = new Date(); - const user = { - id: '1', - name: 'Alex Yang', - email: 'alexyang@example.org', - emailVerified: date, - createdAt: date, - avatarUrl: '', - }; - const result = await authResolver.token( - { - req: { - headers: { - referer: 'https://example.org/open-app/test', - host: 'example.org', - }, - cookies: { - 'next-auth.session-token': '123456', - }, - } as any, - }, - user, - user - ); - t.is(result.sessionToken, '123456'); -}); - -test('verify challenge', async t => { - const resource = 'xp8D3rcXV9bMhWrb6abxl'; - const response = await mintChallengeResponse(resource, 20); - const success = await verifyChallengeResponse(response, 20, resource); - t.true(success); -}); diff --git a/packages/backend/server/tests/feature.spec.ts b/packages/backend/server/tests/feature.spec.ts index 60be45e8bb..fc59bed01f 100644 --- a/packages/backend/server/tests/feature.spec.ts +++ b/packages/backend/server/tests/feature.spec.ts @@ -11,7 +11,7 @@ import { FeatureService, FeatureType, } from '../src/core/features'; -import { UserType } from '../src/core/users/types'; +import { UserType } from '../src/core/user/types'; import { WorkspaceResolver } from '../src/core/workspaces/resolvers'; import { Permission } from '../src/core/workspaces/types'; import { ConfigModule } from '../src/fundamentals/config'; @@ -54,11 +54,6 @@ test.beforeEach(async t => { const { app } = await createTestingApp({ imports: [ ConfigModule.forRoot({ - auth: { - accessTokenExpiresIn: 1, - refreshTokenExpiresIn: 1, - leeway: 1, - }, host: 'example.org', https: true, featureFlags: { diff --git a/packages/backend/server/tests/mailer.e2e.ts b/packages/backend/server/tests/mailer.e2e.ts index 8197c8befa..ecbf0c0e85 100644 --- a/packages/backend/server/tests/mailer.e2e.ts +++ b/packages/backend/server/tests/mailer.e2e.ts @@ -21,15 +21,7 @@ const test = ava as TestFn<{ test.beforeEach(async t => { t.context.module = await createTestingModule({ - imports: [ - ConfigModule.forRoot({ - auth: { - accessTokenExpiresIn: 1, - refreshTokenExpiresIn: 1, - leeway: 1, - }, - }), - ], + imports: [ConfigModule.forRoot({})], }); t.context.auth = t.context.module.get(AuthService); }); diff --git a/packages/backend/server/tests/session.spec.ts b/packages/backend/server/tests/session.spec.ts deleted file mode 100644 index 7e668317b5..0000000000 --- a/packages/backend/server/tests/session.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/// - -import { TestingModule } from '@nestjs/testing'; -import ava, { type TestFn } from 'ava'; - -import { CacheModule } from '../src/fundamentals/cache'; -import { SessionModule, SessionService } from '../src/fundamentals/session'; -import { createTestingModule } from './utils'; - -const test = ava as TestFn<{ - session: SessionService; - module: TestingModule; -}>; - -test.beforeEach(async t => { - const module = await createTestingModule({ - imports: [CacheModule, SessionModule], - }); - const session = module.get(SessionService); - t.context.module = module; - t.context.session = session; -}); - -test.afterEach.always(async t => { - await t.context.module.close(); -}); - -test('should be able to set session', async t => { - const { session } = t.context; - await session.set('test', 'value'); - t.is(await session.get('test'), 'value'); -}); - -test('should be expired by ttl', async t => { - const { session } = t.context; - await session.set('test', 'value', 100); - t.is(await session.get('test'), 'value'); - await new Promise(resolve => setTimeout(resolve, 500)); - t.is(await session.get('test'), undefined); -}); diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts index 3ead722ffd..f57dce3e39 100644 --- a/packages/backend/server/tests/utils/user.ts +++ b/packages/backend/server/tests/utils/user.ts @@ -1,16 +1,18 @@ import type { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; import request from 'supertest'; -import type { TokenType } from '../../src/core/auth'; -import type { UserType } from '../../src/core/users'; +import type { ClientTokenType } from '../../src/core/auth'; +import type { UserType } from '../../src/core/user'; import { gql } from './common'; export async function signUp( app: INestApplication, name: string, email: string, - password: string -): Promise { + password: string, + autoVerifyEmail = true +): Promise { const res = await request(app.getHttpServer()) .post(gql) .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) @@ -24,9 +26,23 @@ export async function signUp( `, }) .expect(200); + + if (autoVerifyEmail) { + await setEmailVerified(app, email); + } + return res.body.data.signUp; } +async function setEmailVerified(app: INestApplication, email: string) { + await app.get(PrismaClient).user.update({ + where: { email }, + data: { + emailVerifiedAt: new Date(), + }, + }); +} + export async function currentUser(app: INestApplication, token: string) { const res = await request(app.getHttpServer()) .post(gql) @@ -36,7 +52,7 @@ export async function currentUser(app: INestApplication, token: string) { query: ` query { currentUser { - id, name, email, emailVerified, avatarUrl, createdAt, hasPassword, + id, name, email, emailVerified, avatarUrl, hasPassword, token { token } } } @@ -94,8 +110,9 @@ export async function sendVerifyChangeEmail( export async function changeEmail( app: INestApplication, userToken: string, - token: string -): Promise { + token: string, + email: string +): Promise { const res = await request(app.getHttpServer()) .post(gql) .auth(userToken, { type: 'bearer' }) @@ -103,7 +120,7 @@ export async function changeEmail( .send({ query: ` mutation { - changeEmail(token: "${token}") { + changeEmail(token: "${token}", email: "${email}") { id name avatarUrl diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index 00cec6b3bd..8d3c32bf55 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -1,11 +1,13 @@ import { INestApplication, ModuleMetadata } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { Query, Resolver } from '@nestjs/graphql'; import { Test, TestingModuleBuilder } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; +import cookieParser from 'cookie-parser'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule, FunctionalityModules } from '../../src/app.module'; -import { AuthModule } from '../../src/core/auth'; +import { AuthGuard, AuthModule } from '../../src/core/auth'; import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init'; import { GqlModule } from '../../src/fundamentals/graphql'; @@ -78,7 +80,14 @@ export async function createTestingModule( const builder = Test.createTestingModule({ imports, - providers: [MockResolver, ...(moduleDef.providers ?? [])], + providers: [ + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + MockResolver, + ...(moduleDef.providers ?? []), + ], controllers: moduleDef.controllers, }); @@ -113,6 +122,8 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { }) ); + app.use(cookieParser()); + if (moduleDef.tapApp) { moduleDef.tapApp(app); } diff --git a/packages/common/env/package.json b/packages/common/env/package.json index 729f8050d9..a0a9ee8fe1 100644 --- a/packages/common/env/package.json +++ b/packages/common/env/package.json @@ -3,8 +3,8 @@ "private": true, "type": "module", "devDependencies": { - "@blocksuite/global": "0.13.0-canary-202403050653-934469c", - "@blocksuite/store": "0.13.0-canary-202403050653-934469c", + "@blocksuite/global": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/store": "0.13.0-canary-202403140320-a2b362b", "react": "18.2.0", "react-dom": "18.2.0", "vitest": "1.3.1" diff --git a/packages/common/env/src/constant.ts b/packages/common/env/src/constant.ts index 179ec41abb..4de96957c4 100644 --- a/packages/common/env/src/constant.ts +++ b/packages/common/env/src/constant.ts @@ -1,5 +1,5 @@ // This file should has not side effect -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; declare global { interface Window { @@ -95,12 +95,12 @@ export const Messages = { }; export class PageNotFoundError extends TypeError { - readonly workspace: Workspace; + readonly docCollection: DocCollection; readonly pageId: string; - constructor(workspace: Workspace, pageId: string) { + constructor(docCollection: DocCollection, pageId: string) { super(); - this.workspace = workspace; + this.docCollection = docCollection; this.pageId = pageId; } } diff --git a/packages/common/env/src/filter.ts b/packages/common/env/src/filter.ts index 948d9beda7..c0c1deb454 100644 --- a/packages/common/env/src/filter.ts +++ b/packages/common/env/src/filter.ts @@ -1,4 +1,4 @@ -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { z } from 'zod'; export const literalValueSchema: z.ZodType = @@ -81,4 +81,4 @@ export const tagSchema = z.object({ }); export type Tag = z.input; -export type PropertiesMeta = Workspace['meta']['properties']; +export type PropertiesMeta = DocCollection['meta']['properties']; diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index 68282751b2..e40c78380a 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -1,17 +1,10 @@ /// import { assertEquals } from '@blocksuite/global/utils'; -import type { Workspace } from '@blocksuite/store'; import { z } from 'zod'; import { isDesktop, isServer } from './constant.js'; import { UaHelper } from './ua-helper.js'; -export const blockSuiteFeatureFlags = z.object({ - enable_synced_doc_block: z.boolean(), - enable_expand_database_block: z.boolean(), - enable_bultin_ledits: z.boolean(), -}); - export const runtimeFlagsSchema = z.object({ enableTestProperties: z.boolean(), enableBroadcastChannelProvider: z.boolean(), @@ -36,7 +29,6 @@ export const runtimeFlagsSchema = z.object({ // this is for the electron app serverUrlPrefix: z.string(), enableMoveDatabase: z.boolean(), - editorFlags: blockSuiteFeatureFlags, appVersion: z.string(), editorVersion: z.string(), appBuildType: z.union([ @@ -48,8 +40,6 @@ export const runtimeFlagsSchema = z.object({ isSelfHosted: z.boolean().optional(), }); -export type BlockSuiteFeatureFlags = z.infer; - export type RuntimeConfig = z.infer; type BrowserBase = { @@ -153,12 +143,3 @@ export function setupGlobal() { globalThis.$AFFINE_SETUP = true; } - -export function setupEditorFlags(workspace: Workspace) { - Object.entries(runtimeConfig.editorFlags).forEach(([key, value]) => { - workspace.awarenessStore.setFlag( - key as keyof BlockSuiteFeatureFlags, - value - ); - }); -} diff --git a/packages/common/infra/package.json b/packages/common/infra/package.json index f1d82e141b..da1c6d8735 100644 --- a/packages/common/infra/package.json +++ b/packages/common/infra/package.json @@ -17,9 +17,9 @@ "@affine/debug": "workspace:*", "@affine/env": "workspace:*", "@affine/templates": "workspace:*", - "@blocksuite/blocks": "0.13.0-canary-202403050653-934469c", - "@blocksuite/global": "0.13.0-canary-202403050653-934469c", - "@blocksuite/store": "0.13.0-canary-202403050653-934469c", + "@blocksuite/blocks": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/global": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/store": "0.13.0-canary-202403140320-a2b362b", "foxact": "^0.2.31", "jotai": "^2.6.5", "jotai-effect": "^0.6.0", @@ -33,8 +33,8 @@ "devDependencies": { "@affine-test/fixtures": "workspace:*", "@affine/templates": "workspace:*", - "@blocksuite/lit": "0.13.0-canary-202403050653-934469c", - "@blocksuite/presets": "0.13.0-canary-202403050653-934469c", + "@blocksuite/lit": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/presets": "0.13.0-canary-202403140320-a2b362b", "@testing-library/react": "^14.2.1", "async-call-rpc": "^6.4.0", "react": "^18.2.0", diff --git a/packages/common/infra/src/atom/settings.ts b/packages/common/infra/src/atom/settings.ts index ac66005dc3..f71f46e80f 100644 --- a/packages/common/infra/src/atom/settings.ts +++ b/packages/common/infra/src/atom/settings.ts @@ -1,10 +1,16 @@ +import { DebugLogger } from '@affine/debug'; import { setupGlobal } from '@affine/env/global'; +import type { DocCollection } from '@blocksuite/store'; import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import { atomEffect } from 'jotai-effect'; +import { getCurrentStore } from './root-store'; + setupGlobal(); +const logger = new DebugLogger('affine:settings'); + export type DateFormats = | 'MM/dd/YYYY' | 'dd/MM/YYYY' @@ -25,6 +31,8 @@ export type AppSetting = { enableNoisyBackground: boolean; autoCheckUpdate: boolean; autoDownloadUpdate: boolean; + enableMultiView: boolean; + editorFlags: Partial>; }; export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [ 'frameless', @@ -63,15 +71,36 @@ const appSettingBaseAtom = atomWithStorage('affine-settings', { enableNoisyBackground: true, autoCheckUpdate: true, autoDownloadUpdate: true, + enableMultiView: false, + editorFlags: {}, }); +export function setupEditorFlags(docCollection: DocCollection) { + const store = getCurrentStore(); + const syncEditorFlags = () => { + try { + const editorFlags = getCurrentStore().get(appSettingBaseAtom).editorFlags; + Object.entries(editorFlags).forEach(([key, value]) => { + docCollection.awarenessStore.setFlag( + key as keyof BlockSuiteFlags, + value + ); + }); + } catch (err) { + logger.error('syncEditorFlags', err); + } + }; + store.sub(appSettingBaseAtom, syncEditorFlags); + syncEditorFlags(); +} + type SetStateAction = Value | ((prev: Value) => Value); const appSettingEffect = atomEffect(get => { const settings = get(appSettingBaseAtom); // some values in settings should be synced into electron side if (environment.isDesktop) { - console.log('set config', settings); + logger.debug('sync settings to electron', settings); // this api type in @affine/electron-api, but it is circular dependency this package, use any here (window as any).apis?.updater .setConfig({ diff --git a/packages/common/infra/src/blocksuite/migration/blocksuite.ts b/packages/common/infra/src/blocksuite/migration/blocksuite.ts index 2fc17d9ac1..f07a55cd7d 100644 --- a/packages/common/infra/src/blocksuite/migration/blocksuite.ts +++ b/packages/common/infra/src/blocksuite/migration/blocksuite.ts @@ -35,7 +35,7 @@ export async function migratePages( console.error(e); } }); - schema.upgradeWorkspace(rootDoc); + schema.upgradeCollection(rootDoc); // Hard code to upgrade page version to 2. // Let e2e to ensure the data version is correct. diff --git a/packages/common/infra/src/blocksuite/migration/fixing.ts b/packages/common/infra/src/blocksuite/migration/fixing.ts index 5813da65da..840cb5a623 100644 --- a/packages/common/infra/src/blocksuite/migration/fixing.ts +++ b/packages/common/infra/src/blocksuite/migration/fixing.ts @@ -13,6 +13,9 @@ export function fixWorkspaceVersion(rootDoc: YDoc) { * Blocksuite just set the value, do nothing else. */ function doFix() { + if (meta.size === 0) { + return; + } const workspaceVersion = meta.get('workspaceVersion'); if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) { transact( diff --git a/packages/common/infra/src/blocksuite/migration/workspace.ts b/packages/common/infra/src/blocksuite/migration/workspace.ts index b19a7f7122..f39819c8b5 100644 --- a/packages/common/infra/src/blocksuite/migration/workspace.ts +++ b/packages/common/infra/src/blocksuite/migration/workspace.ts @@ -1,4 +1,4 @@ -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import type { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs'; /** @@ -11,14 +11,14 @@ export enum MigrationPoint { } export function checkWorkspaceCompatibility( - workspace: Workspace, + docCollection: DocCollection, isCloud: boolean ): MigrationPoint | null { // check if there is any key starts with 'space:' on root doc - const spaceMetaObj = workspace.doc.share.get('space:meta') as + const spaceMetaObj = docCollection.doc.share.get('space:meta') as | YMap | undefined; - const docKeys = Array.from(workspace.doc.share.keys()); + const docKeys = Array.from(docCollection.doc.share.keys()); const haveSpaceMeta = !!spaceMetaObj && spaceMetaObj.size > 0; const haveLegacySpace = docKeys.some(key => key.startsWith('space:')); @@ -28,12 +28,12 @@ export function checkWorkspaceCompatibility( } // exit if no pages - if (!workspace.meta.docs?.length) { + if (!docCollection.meta.docs?.length) { return null; } // check guid compatibility - const meta = workspace.doc.getMap('meta') as YMap; + const meta = docCollection.doc.getMap('meta') as YMap; const pages = meta.get('pages') as YArray>; for (const page of pages) { const pageId = page.get('id') as string | undefined; @@ -41,23 +41,23 @@ export function checkWorkspaceCompatibility( return MigrationPoint.GuidFix; } } - const spaces = workspace.doc.getMap('spaces') as YMap; + const spaces = docCollection.doc.getMap('spaces') as YMap; for (const [pageId, _] of spaces) { if (pageId.includes(':')) { return MigrationPoint.GuidFix; } } - const hasVersion = workspace.meta.hasVersion; + const hasVersion = docCollection.meta.hasVersion; if (!hasVersion) { return MigrationPoint.BlockVersion; } // TODO: Catch compatibility error from blocksuite to show upgrade page. // Temporarily follow the check logic of blocksuite. - if ((workspace.meta.docs?.length ?? 0) <= 1) { + if ((docCollection.meta.docs?.length ?? 0) <= 1) { try { - workspace.meta.validateVersion(workspace); + docCollection.meta.validateVersion(docCollection); } catch (e) { console.info('validateVersion error', e); return MigrationPoint.BlockVersion; @@ -65,9 +65,9 @@ export function checkWorkspaceCompatibility( } // From v2, we depend on blocksuite to check and migrate data. - const blockVersions = workspace.meta.blockVersions; + const blockVersions = docCollection.meta.blockVersions; for (const [flavour, version] of Object.entries(blockVersions ?? {})) { - const schema = workspace.schema.flavourSchemaMap.get(flavour); + const schema = docCollection.schema.flavourSchemaMap.get(flavour); if (schema?.version !== version) { return MigrationPoint.BlockVersion; } diff --git a/packages/common/infra/src/initialization/index.ts b/packages/common/infra/src/initialization/index.ts index 032e99a524..9390cc1780 100644 --- a/packages/common/infra/src/initialization/index.ts +++ b/packages/common/infra/src/initialization/index.ts @@ -1,9 +1,9 @@ import type { WorkspaceFlavour } from '@affine/env/workspace'; import type { + CollectionInfoSnapshot, Doc, DocSnapshot, JobMiddleware, - WorkspaceInfoSnapshot, } from '@blocksuite/store'; import { Job } from '@blocksuite/store'; import { Map as YMap } from 'yjs'; @@ -49,17 +49,17 @@ export async function buildShowcaseWorkspace( ) { const meta = await workspaceManager.createWorkspace( flavour, - async (blockSuiteWorkspace, blobStorage) => { - blockSuiteWorkspace.meta.setName(workspaceName); + async (docCollection, blobStorage) => { + docCollection.meta.setName(workspaceName); const { onboarding } = await import('@affine/templates'); - const info = onboarding['info.json'] as WorkspaceInfoSnapshot; + const info = onboarding['info.json'] as CollectionInfoSnapshot; const blob = onboarding['blob.json'] as { [key: string]: string }; - const migrationMiddleware: JobMiddleware = ({ slots, workspace }) => { + const migrationMiddleware: JobMiddleware = ({ slots, collection }) => { slots.afterImport.on(payload => { if (payload.type === 'page') { - workspace.schema.upgradeDoc( + collection.schema.upgradeDoc( info?.pageVersion ?? 0, {}, payload.page.spaceDoc @@ -69,11 +69,11 @@ export async function buildShowcaseWorkspace( }; const job = new Job({ - workspace: blockSuiteWorkspace, + collection: docCollection, middlewares: [replaceIdMiddleware, migrationMiddleware], }); - job.snapshotToWorkspaceInfo(info); + job.snapshotToCollectionInfo(info); // for now all onboarding assets are considered served via CDN // hack assets so that every blob exists @@ -92,8 +92,8 @@ export async function buildShowcaseWorkspace( }) ); - const newVersions = getLatestVersions(blockSuiteWorkspace.schema); - blockSuiteWorkspace.doc + const newVersions = getLatestVersions(docCollection.schema); + docCollection.doc .getMap('meta') .set('blockVersions', new YMap(Object.entries(newVersions))); diff --git a/packages/common/infra/src/livedata/__tests__/livedata.spec.ts b/packages/common/infra/src/livedata/__tests__/livedata.spec.ts index a48545a9a2..dec3f98972 100644 --- a/packages/common/infra/src/livedata/__tests__/livedata.spec.ts +++ b/packages/common/infra/src/livedata/__tests__/livedata.spec.ts @@ -2,7 +2,7 @@ import type { Subscriber } from 'rxjs'; import { combineLatest, Observable, of } from 'rxjs'; import { describe, expect, test, vitest } from 'vitest'; -import { LiveData } from '..'; +import { LiveData, PoisonedError } from '..'; describe('livedata', () => { test('LiveData', async () => { @@ -133,6 +133,47 @@ describe('livedata', () => { } }); + test('poisoned', () => { + { + let subscriber: Subscriber = null!; + const livedata = LiveData.from( + new Observable(sub => { + subscriber = sub; + }), + 1 + ); + + let value: number = 0; + let error: any = null; + livedata.subscribe({ + next: v => { + value = v; + }, + error: e => { + error = e; + }, + }); + expect(value).toBe(1); + subscriber.next(2); + expect(value).toBe(2); + + expect(error).toBe(null); + subscriber.error('error'); + expect(error).toBeInstanceOf(PoisonedError); + + expect(() => livedata.next(3)).toThrowError(PoisonedError); + expect(() => livedata.value).toThrowError(PoisonedError); + + let error2: any = null; + livedata.subscribe({ + error: e => { + error2 = e; + }, + }); + expect(error2).toBeInstanceOf(PoisonedError); + } + }); + test('map', () => { { const livedata = new LiveData(0); @@ -185,4 +226,105 @@ describe('livedata', () => { }); expect(value).toBe(1); }); + + test('flat', () => { + { + const wrapped = new LiveData(new LiveData(0)); + const flatten = wrapped.flat(); + expect(flatten.value).toBe(0); + + wrapped.next(new LiveData(1)); + expect(flatten.value).toBe(1); + + wrapped.next(LiveData.from(of(2, 3), 0)); + expect(flatten.value).toBe(3); + } + + { + const wrapped = new LiveData( + new LiveData([ + new LiveData(new LiveData(1)), + new LiveData(new LiveData(2)), + ]) + ); + const flatten = wrapped.flat(); + expect(flatten.value).toStrictEqual([1, 2]); + } + + { + const wrapped = new LiveData([new LiveData(0), new LiveData(1)]); + const flatten = wrapped.flat(); + + expect(flatten.value).toEqual([0, 1]); + + const inner = new LiveData(2); + wrapped.next([inner, new LiveData(3)]); + expect(flatten.value).toEqual([2, 3]); + inner.next(4); + expect(flatten.value).toEqual([4, 3]); + } + }); + + test('computed', () => { + { + const a = new LiveData(1); + const b = LiveData.computed(get => get(a) + 1); + expect(b.value).toBe(2); + } + + { + const a = new LiveData('v1'); + const v1 = new LiveData(100); + const v2 = new LiveData(200); + + const v = LiveData.computed(get => { + return get(a) === 'v1' ? get(v1) : get(v2); + }); + + expect(v.value).toBe(100); + + a.next('v2'); + expect(v.value).toBe(200); + } + + { + let watched = false; + let count = 0; + let subscriber: Subscriber = null!; + const a = LiveData.from( + new Observable(sub => { + count++; + watched = true; + subscriber = sub; + sub.next(1); + return () => { + watched = false; + }; + }), + 0 + ); + const b = LiveData.computed(get => get(a) + 1); + + expect(watched).toBe(false); + expect(count).toBe(0); + + const subscription = b.subscribe(_ => {}); + expect(watched).toBe(true); + expect(count).toBe(1); + subscriber.next(2); + expect(b.value).toBe(3); + + subscription.unsubscribe(); + expect(watched).toBe(false); + expect(count).toBe(1); + } + + { + let c = null! as LiveData; + const b = LiveData.computed(get => get(c) + 1); + c = LiveData.computed(get => get(b) + 1); + + expect(() => b.value).toThrowError(PoisonedError); + } + }); }); diff --git a/packages/common/infra/src/livedata/index.ts b/packages/common/infra/src/livedata/index.ts index cc9259a9b1..06524dc4cf 100644 --- a/packages/common/infra/src/livedata/index.ts +++ b/packages/common/infra/src/livedata/index.ts @@ -1,5 +1,6 @@ import { DebugLogger } from '@affine/debug'; import { + combineLatest, distinctUntilChanged, EMPTY, filter, @@ -131,10 +132,100 @@ export class LiveData implements InteropObservable { return data; } + private static GLOBAL_COMPUTED_RECURSIVE_COUNT = 0; + + /** + * @example + * ```ts + * const a = new LiveData('v1'); + * const v1 = new LiveData(100); + * const v2 = new LiveData(200); + * + * const v = LiveData.computed(get => { + * return get(a) === 'v1' ? get(v1) : get(v2); + * }); + * + * expect(v.value).toBe(100); + * ``` + */ + static computed( + compute: (get: (data: LiveData) => L) => T + ): LiveData { + return LiveData.from( + new Observable(subscribe => { + const execute = (next: () => void) => { + const subscriptions: Subscription[] = []; + const getfn = (data: LiveData) => { + let value = null as L; + let first = true; + subscriptions.push( + data.subscribe({ + error(err) { + subscribe.error(err); + }, + next(v) { + value = v; + if (!first) { + next(); + } + first = false; + }, + }) + ); + return value; + }; + + LiveData.GLOBAL_COMPUTED_RECURSIVE_COUNT++; + + try { + if (LiveData.GLOBAL_COMPUTED_RECURSIVE_COUNT > 10) { + subscribe.error(new Error('computed recursive limit exceeded')); + } else { + subscribe.next(compute(getfn)); + } + } catch (err) { + subscribe.error(err); + } finally { + LiveData.GLOBAL_COMPUTED_RECURSIVE_COUNT--; + } + + return () => { + subscriptions.forEach(s => s.unsubscribe()); + }; + }; + + let prev = () => {}; + + const looper = () => { + const dispose = execute(looper); + prev(); + prev = dispose; + }; + + looper(); + + return () => { + prev(); + }; + }), + null as any + ); + } + private readonly raw: BehaviorSubject; private readonly ops = new Subject(); private readonly upstreamSubscription: Subscription | undefined; + /** + * When the upstream Observable of livedata throws an error, livedata will enter poisoned state. This is an + * unrecoverable abnormal state. Any operation on livedata will throw a PoisonedError. + * + * Since the development specification for livedata is not to throw any error, entering the poisoned state usually + * means a programming error. + */ + private isPoisoned = false; + private poisonedError: PoisonedError | null = null; + constructor( initialValue: T, upstream: @@ -154,17 +245,26 @@ export class LiveData implements InteropObservable { }, error: err => { logger.error('uncatched error in livedata', err); + this.isPoisoned = true; + this.poisonedError = new PoisonedError(err); + this.raw.error(this.poisonedError); }, }); } } getValue(): T { + if (this.isPoisoned) { + throw this.poisonedError; + } this.ops.next('get'); return this.raw.value; } setValue(v: T) { + if (this.isPoisoned) { + throw this.poisonedError; + } this.raw.next(v); this.ops.next('set'); } @@ -174,10 +274,13 @@ export class LiveData implements InteropObservable { } set value(v: T) { - this.setValue(v); + this.next(v); } next(v: T) { + if (this.isPoisoned) { + throw this.poisonedError; + } this.setValue(v); } @@ -192,7 +295,7 @@ export class LiveData implements InteropObservable { return subscription; } - map(mapper: (v: T) => R): LiveData { + map(mapper: (v: T) => R) { const sub = LiveData.from( new Observable(subscriber => this.subscribe({ @@ -268,7 +371,46 @@ export class LiveData implements InteropObservable { this.upstreamSubscription?.unsubscribe(); } + /** + * flatten the livedata + * + * ``` + * new LiveData(new LiveData(0)).flat() // LiveData + * ``` + * + * ``` + * new LiveData([new LiveData(0)]).flat() // LiveData + * ``` + */ + flat(): Flat { + return LiveData.from( + this.pipe( + switchMap(v => { + if (v instanceof LiveData) { + return (v as LiveData).flat(); + } else if (Array.isArray(v)) { + return combineLatest( + v.map(v => { + if (v instanceof LiveData) { + return v.flat(); + } else { + return of(v); + } + }) + ); + } else { + return of(v); + } + }) + ), + null as any + ) as any; + } + reactSubscribe = (cb: () => void) => { + if (this.isPoisoned) { + throw this.poisonedError; + } this.ops.next('watch'); const subscription = this.raw .pipe(distinctUntilChanged(), skip(1)) @@ -280,6 +422,9 @@ export class LiveData implements InteropObservable { }; reactGetSnapshot = () => { + if (this.isPoisoned) { + throw this.poisonedError; + } this.ops.next('watch'); setImmediate(() => { this.ops.next('unwatch'); @@ -297,3 +442,21 @@ export class LiveData implements InteropObservable { } export type LiveDataOperation = 'set' | 'get' | 'watch' | 'unwatch'; + +export type Unwrap = + T extends LiveData + ? Unwrap + : T extends LiveData[] + ? Unwrap[] + : T; + +export type Flat = T extends LiveData ? LiveData> : T; + +export class PoisonedError extends Error { + constructor(originalError: any) { + super( + 'The livedata is poisoned, original error: ' + + (originalError instanceof Error ? originalError.stack : originalError) + ); + } +} diff --git a/packages/common/infra/src/page/manager.ts b/packages/common/infra/src/page/manager.ts index 9725e7e9bc..c5ff670549 100644 --- a/packages/common/infra/src/page/manager.ts +++ b/packages/common/infra/src/page/manager.ts @@ -20,7 +20,7 @@ export class PageManager { if (!pageRecord) { throw new Error('Page record not found'); } - const blockSuitePage = this.workspace.blockSuiteWorkspace.getDoc(pageId); + const blockSuitePage = this.workspace.docCollection.getDoc(pageId); if (!blockSuitePage) { throw new Error('Page not found'); } diff --git a/packages/common/infra/src/page/record-list.ts b/packages/common/infra/src/page/record-list.ts index c27b359265..5153548953 100644 --- a/packages/common/infra/src/page/record-list.ts +++ b/packages/common/infra/src/page/record-list.ts @@ -18,7 +18,7 @@ export class PageRecordList { new Observable(subscriber => { const emit = () => { subscriber.next( - this.workspace.blockSuiteWorkspace.meta.docMetas.map( + this.workspace.docCollection.meta.docMetas.map( v => new PageRecord(v.id, this.workspace, this.localState) ) ); @@ -27,7 +27,7 @@ export class PageRecordList { emit(); const dispose = - this.workspace.blockSuiteWorkspace.meta.docMetaUpdated.on(emit).dispose; + this.workspace.docCollection.meta.docMetaUpdated.on(emit).dispose; return () => { dispose(); }; diff --git a/packages/common/infra/src/page/record.ts b/packages/common/infra/src/page/record.ts index e6f109ae64..b3276b5b76 100644 --- a/packages/common/infra/src/page/record.ts +++ b/packages/common/infra/src/page/record.ts @@ -16,7 +16,7 @@ export class PageRecord { meta = LiveData.from( new Observable(subscriber => { const emit = () => { - const meta = this.workspace.blockSuiteWorkspace.meta.docMetas.find( + const meta = this.workspace.docCollection.meta.docMetas.find( page => page.id === this.id ); if (meta === undefined) { @@ -28,7 +28,7 @@ export class PageRecord { emit(); const dispose = - this.workspace.blockSuiteWorkspace.meta.docMetaUpdated.on(emit).dispose; + this.workspace.docCollection.meta.docMetaUpdated.on(emit).dispose; return () => { dispose(); }; @@ -42,7 +42,7 @@ export class PageRecord { ); setMeta(meta: Partial): void { - this.workspace.blockSuiteWorkspace.setDocMeta(this.id, meta); + this.workspace.docCollection.setDocMeta(this.id, meta); } mode: LiveData = LiveData.from( diff --git a/packages/common/infra/src/workspace/__tests__/workspace.spec.ts b/packages/common/infra/src/workspace/__tests__/workspace.spec.ts index a239341a65..1a1182ba1f 100644 --- a/packages/common/infra/src/workspace/__tests__/workspace.spec.ts +++ b/packages/common/infra/src/workspace/__tests__/workspace.spec.ts @@ -22,7 +22,7 @@ describe('Workspace System', () => { expect(workspaceListService.workspaceList.value.length).toBe(1); - const page = workspace.blockSuiteWorkspace.createDoc({ + const page = workspace.docCollection.createDoc({ id: 'page0', }); page.load(); @@ -30,7 +30,7 @@ describe('Workspace System', () => { title: new page.Text('test-page'), }); - expect(workspace.blockSuiteWorkspace.docs.size).toBe(1); + expect(workspace.docCollection.docs.size).toBe(1); expect( (page!.getBlockByFlavour('affine:page')[0] as any).title.toString() ).toBe('test-page'); diff --git a/packages/common/infra/src/workspace/context.ts b/packages/common/infra/src/workspace/context.ts index 41e2f8d248..ca7db30229 100644 --- a/packages/common/infra/src/workspace/context.ts +++ b/packages/common/infra/src/workspace/context.ts @@ -18,7 +18,7 @@ * }) */ -import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import { nanoid } from 'nanoid'; import type { Awareness } from 'y-protocols/awareness.js'; import type { Doc as YDoc } from 'yjs'; @@ -29,7 +29,7 @@ import { globalBlockSuiteSchema } from './global-schema'; import type { WorkspaceMetadata } from './metadata'; import { WorkspaceScope } from './service-scope'; -export const BlockSuiteWorkspaceContext = createIdentifier( +export const BlockSuiteWorkspaceContext = createIdentifier( 'BlockSuiteWorkspaceContext' ); @@ -53,7 +53,7 @@ export function configureWorkspaceContext( .addImpl(WorkspaceMetadataContext, workspaceMetadata) .addImpl(WorkspaceIdContext, workspaceMetadata.id) .addImpl(BlockSuiteWorkspaceContext, provider => { - return new BlockSuiteWorkspace({ + return new DocCollection({ id: workspaceMetadata.id, blobStorages: [ () => ({ diff --git a/packages/common/infra/src/workspace/engine/sync/__tests__/engine.spec.ts b/packages/common/infra/src/workspace/engine/sync/__tests__/engine.spec.ts index 6bf9bd4b72..7e2580f94c 100644 --- a/packages/common/infra/src/workspace/engine/sync/__tests__/engine.spec.ts +++ b/packages/common/infra/src/workspace/engine/sync/__tests__/engine.spec.ts @@ -1,5 +1,5 @@ import { WorkspaceFlavour } from '@affine/env/workspace'; -import { Workspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { Doc } from 'yjs'; @@ -26,14 +26,14 @@ describe('SyncEngine', () => { const storage2 = new MemoryMemento(); let prev: any; { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test', schema: globalBlockSuiteSchema, }); const syncEngine = new SyncEngine( - workspace.doc, + docCollection.doc, new TestingSyncStorage(testMeta, storage), [ new TestingSyncStorage(testMeta, storage1), @@ -42,7 +42,7 @@ describe('SyncEngine', () => { ); syncEngine.start(); - const page = workspace.createDoc({ + const page = docCollection.createDoc({ id: 'page0', }); page.load(); @@ -69,23 +69,23 @@ describe('SyncEngine', () => { ); await syncEngine.waitForSynced(); syncEngine.forceStop(); - prev = workspace.doc.toJSON(); + prev = docCollection.doc.toJSON(); } for (const current of [storage, storage1, storage2]) { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test', schema: globalBlockSuiteSchema, }); const syncEngine = new SyncEngine( - workspace.doc, + docCollection.doc, new TestingSyncStorage(testMeta, current), [] ); syncEngine.start(); await syncEngine.waitForSynced(); - expect(workspace.doc.toJSON()).toEqual({ + expect(docCollection.doc.toJSON()).toEqual({ ...prev, }); syncEngine.forceStop(); diff --git a/packages/common/infra/src/workspace/engine/sync/__tests__/peer.spec.ts b/packages/common/infra/src/workspace/engine/sync/__tests__/peer.spec.ts index 4bc033a09b..f0cf51d633 100644 --- a/packages/common/infra/src/workspace/engine/sync/__tests__/peer.spec.ts +++ b/packages/common/infra/src/workspace/engine/sync/__tests__/peer.spec.ts @@ -1,5 +1,5 @@ import { WorkspaceFlavour } from '@affine/env/workspace'; -import { Workspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { MemoryMemento } from '../../../../storage'; @@ -23,19 +23,19 @@ describe('SyncPeer', () => { let prev: any; { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test', schema: globalBlockSuiteSchema, }); const syncPeer = new SyncPeer( - workspace.doc, + docCollection.doc, new TestingSyncStorage(testMeta, storage) ); await syncPeer.waitForLoaded(); - const page = workspace.createDoc({ + const page = docCollection.createDoc({ id: 'page0', }); page.load(); @@ -62,21 +62,21 @@ describe('SyncPeer', () => { ); await syncPeer.waitForSynced(); syncPeer.stop(); - prev = workspace.doc.toJSON(); + prev = docCollection.doc.toJSON(); } { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test', schema: globalBlockSuiteSchema, }); const syncPeer = new SyncPeer( - workspace.doc, + docCollection.doc, new TestingSyncStorage(testMeta, storage) ); await syncPeer.waitForSynced(); - expect(workspace.doc.toJSON()).toEqual({ + expect(docCollection.doc.toJSON()).toEqual({ ...prev, }); syncPeer.stop(); @@ -86,21 +86,21 @@ describe('SyncPeer', () => { test('status', async () => { const storage = new MemoryMemento(); - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test', schema: globalBlockSuiteSchema, }); const syncPeer = new SyncPeer( - workspace.doc, + docCollection.doc, new TestingSyncStorage(testMeta, storage) ); expect(syncPeer.status.step).toBe(SyncPeerStep.LoadingRootDoc); await syncPeer.waitForSynced(); expect(syncPeer.status.step).toBe(SyncPeerStep.Synced); - const page = workspace.createDoc({ + const page = docCollection.createDoc({ id: 'page0', }); expect(syncPeer.status.step).toBe(SyncPeerStep.LoadingSubDoc); diff --git a/packages/common/infra/src/workspace/engine/sync/engine.ts b/packages/common/infra/src/workspace/engine/sync/engine.ts index 375682889f..d2a62cec4b 100644 --- a/packages/common/infra/src/workspace/engine/sync/engine.ts +++ b/packages/common/infra/src/workspace/engine/sync/engine.ts @@ -68,15 +68,17 @@ export class SyncEngine { this.onStatusChange.emit(s); } isRootDocLoaded = LiveData.from( - new Observable(observer => { + new Observable(observer => { observer.next( - this.status.local - ? this.status.local.step > SyncPeerStep.LoadingRootDoc - : false + [this.status?.local, ...(this.status?.remotes ?? [])].some( + p => p?.rootDocLoaded === true + ) ); this.onStatusChange.on(status => { observer.next( - status.local ? status.local.step > SyncPeerStep.LoadingRootDoc : false + [status?.local, ...(status?.remotes ?? [])].some( + p => p?.rootDocLoaded === true + ) ); }); }), diff --git a/packages/common/infra/src/workspace/engine/sync/peer.ts b/packages/common/infra/src/workspace/engine/sync/peer.ts index 5a0900ac51..f7c9673c60 100644 --- a/packages/common/infra/src/workspace/engine/sync/peer.ts +++ b/packages/common/infra/src/workspace/engine/sync/peer.ts @@ -20,6 +20,7 @@ export interface SyncPeerStatus { pendingPullUpdates: number; pendingPushUpdates: number; lastError: string | null; + rootDocLoaded: boolean; } /** @@ -56,6 +57,7 @@ export class SyncPeer { pendingPullUpdates: 0, pendingPushUpdates: 0, lastError: null, + rootDocLoaded: false, }; onStatusChange = new Slot(); readonly abort = new AbortController(); @@ -122,6 +124,7 @@ export class SyncPeer { pendingPullUpdates: 0, pendingPushUpdates: 0, lastError: 'Retrying sync after 5 seconds', + rootDocLoaded: this.status.rootDocLoaded, }; await Promise.race([ new Promise(resolve => { @@ -295,6 +298,13 @@ export class SyncPeer { (await this.storage.pull(doc.guid, encodeStateVector(doc))) ?? {}; throwIfAborted(abort); + if (docData !== undefined && doc.guid === this.rootDoc.guid) { + this.status = { + ...this.status, + rootDocLoaded: true, + }; + } + if (docData) { applyUpdate(doc, docData, 'load'); } @@ -400,6 +410,7 @@ export class SyncPeer { pendingPushUpdates: this.state.pushUpdatesQueue.length + (this.state.pushingUpdate ? 1 : 0), lastError, + rootDocLoaded: this.status.rootDocLoaded, }; } diff --git a/packages/common/infra/src/workspace/list/index.ts b/packages/common/infra/src/workspace/list/index.ts index ff414bc1d6..3c90a7fa50 100644 --- a/packages/common/infra/src/workspace/list/index.ts +++ b/packages/common/infra/src/workspace/list/index.ts @@ -1,6 +1,6 @@ import { DebugLogger } from '@affine/debug'; import type { WorkspaceFlavour } from '@affine/env/workspace'; -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { differenceWith } from 'lodash-es'; import { createIdentifier } from '../../di'; @@ -34,7 +34,7 @@ export interface WorkspaceListProvider { */ create( initial: ( - workspace: BlockSuiteWorkspace, + docCollection: DocCollection, blobStorage: BlobStorage ) => Promise ): Promise; @@ -124,7 +124,7 @@ export class WorkspaceListService { async create( flavour: WorkspaceFlavour, initial: ( - workspace: BlockSuiteWorkspace, + docCollection: DocCollection, blobStorage: BlobStorage ) => Promise = () => Promise.resolve() ) { diff --git a/packages/common/infra/src/workspace/list/information.ts b/packages/common/infra/src/workspace/list/information.ts index e143d72e50..a9ff2363c5 100644 --- a/packages/common/infra/src/workspace/list/information.ts +++ b/packages/common/infra/src/workspace/list/information.ts @@ -57,13 +57,13 @@ export class WorkspaceInformation { */ syncWithWorkspace(workspace: Workspace) { this.info = { - avatar: workspace.blockSuiteWorkspace.meta.avatar ?? this.info.avatar, - name: workspace.blockSuiteWorkspace.meta.name ?? this.info.name, + avatar: workspace.docCollection.meta.avatar ?? this.info.avatar, + name: workspace.docCollection.meta.name ?? this.info.name, }; - workspace.blockSuiteWorkspace.meta.commonFieldsUpdated.on(() => { + workspace.docCollection.meta.commonFieldsUpdated.on(() => { this.info = { - avatar: workspace.blockSuiteWorkspace.meta.avatar ?? this.info.avatar, - name: workspace.blockSuiteWorkspace.meta.name ?? this.info.name, + avatar: workspace.docCollection.meta.avatar ?? this.info.avatar, + name: workspace.docCollection.meta.name ?? this.info.name, }; }); } diff --git a/packages/common/infra/src/workspace/manager.ts b/packages/common/infra/src/workspace/manager.ts index c4c8fd756d..13cc11409e 100644 --- a/packages/common/infra/src/workspace/manager.ts +++ b/packages/common/infra/src/workspace/manager.ts @@ -1,10 +1,10 @@ import { DebugLogger } from '@affine/debug'; -import { setupEditorFlags } from '@affine/env/global'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { assertEquals } from '@blocksuite/global/utils'; -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; +import { setupEditorFlags } from '../atom/settings'; import { fixWorkspaceVersion } from '../blocksuite'; import type { ServiceCollection, ServiceProvider } from '../di'; import { ObjectPool } from '../utils/object-pool'; @@ -105,7 +105,7 @@ export class WorkspaceManager { createWorkspace( flavour: WorkspaceFlavour, initial?: ( - workspace: BlockSuiteWorkspace, + docCollection: DocCollection, blobStorage: BlobStorage ) => Promise ): Promise { @@ -131,9 +131,9 @@ export class WorkspaceManager { const newId = await this.list.create( WorkspaceFlavour.AFFINE_CLOUD, async (ws, bs) => { - applyUpdate(ws.doc, encodeStateAsUpdate(local.blockSuiteWorkspace.doc)); + applyUpdate(ws.doc, encodeStateAsUpdate(local.docCollection.doc)); - for (const subdoc of local.blockSuiteWorkspace.doc.getSubdocs()) { + for (const subdoc of local.docCollection.doc.getSubdocs()) { for (const newSubdoc of ws.doc.getSubdocs()) { if (newSubdoc.guid === subdoc.guid) { applyUpdate(newSubdoc, encodeStateAsUpdate(subdoc)); @@ -191,9 +191,9 @@ export class WorkspaceManager { const workspace = provider.get(Workspace); // apply compatibility fix - fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc); + fixWorkspaceVersion(workspace.docCollection.doc); - setupEditorFlags(workspace.blockSuiteWorkspace); + setupEditorFlags(workspace.docCollection); return workspace; } diff --git a/packages/common/infra/src/workspace/testing.ts b/packages/common/infra/src/workspace/testing.ts index 6f2b364b0f..8c17e78526 100644 --- a/packages/common/infra/src/workspace/testing.ts +++ b/packages/common/infra/src/workspace/testing.ts @@ -1,5 +1,5 @@ import { WorkspaceFlavour } from '@affine/env/workspace'; -import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import { differenceBy } from 'lodash-es'; import { nanoid } from 'nanoid'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; @@ -43,7 +43,7 @@ export class TestingLocalWorkspaceListProvider } async create( initial: ( - workspace: BlockSuiteWorkspace, + docCollection: DocCollection, blobStorage: BlobStorage ) => Promise ): Promise { @@ -53,18 +53,18 @@ export class TestingLocalWorkspaceListProvider const blobStorage = new TestingBlobStorage(meta, this.state); const syncStorage = new TestingSyncStorage(meta, this.state); - const workspace = new BlockSuiteWorkspace({ + const docCollection = new DocCollection({ id: id, idGenerator: () => nanoid(), schema: globalBlockSuiteSchema, }); // apply initial state - await initial(workspace, blobStorage); + await initial(docCollection, blobStorage); // save workspace to storage - await syncStorage.push(id, encodeStateAsUpdate(workspace.doc)); - for (const subdocs of workspace.doc.getSubdocs()) { + await syncStorage.push(id, encodeStateAsUpdate(docCollection.doc)); + for (const subdocs of docCollection.doc.getSubdocs()) { await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs)); } @@ -117,7 +117,7 @@ export class TestingLocalWorkspaceListProvider return; } - const bs = new BlockSuiteWorkspace({ + const bs = new DocCollection({ id, schema: globalBlockSuiteSchema, }); diff --git a/packages/common/infra/src/workspace/upgrade.ts b/packages/common/infra/src/workspace/upgrade.ts index 2b57dafce4..ef7dcd6c6c 100644 --- a/packages/common/infra/src/workspace/upgrade.ts +++ b/packages/common/infra/src/workspace/upgrade.ts @@ -1,7 +1,7 @@ import { Unreachable } from '@affine/env/constant'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { Slot } from '@blocksuite/global/utils'; -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs'; import { checkWorkspaceCompatibility, MigrationPoint } from '../blocksuite'; @@ -38,18 +38,18 @@ export class WorkspaceUpgradeController { } constructor( - private readonly blockSuiteWorkspace: BlockSuiteWorkspace, + private readonly docCollection: DocCollection, private readonly sync: SyncEngine, private readonly workspaceMetadata: WorkspaceMetadata ) { - blockSuiteWorkspace.doc.on('update', () => { + docCollection.doc.on('update', () => { this.checkIfNeedUpgrade(); }); } checkIfNeedUpgrade() { const needUpgrade = !!checkWorkspaceCompatibility( - this.blockSuiteWorkspace, + this.docCollection, this.workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD ); this.status = { @@ -72,7 +72,7 @@ export class WorkspaceUpgradeController { await this.sync.waitForSynced(); const step = checkWorkspaceCompatibility( - this.blockSuiteWorkspace, + this.docCollection, this.workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD ); @@ -82,9 +82,9 @@ export class WorkspaceUpgradeController { // Clone a new doc to prevent change events. const clonedDoc = new YDoc({ - guid: this.blockSuiteWorkspace.doc.guid, + guid: this.docCollection.doc.guid, }); - applyDoc(clonedDoc, this.blockSuiteWorkspace.doc); + applyDoc(clonedDoc, this.docCollection.doc); if (step === MigrationPoint.SubDoc) { const newWorkspace = await workspaceManager.createWorkspace( @@ -92,14 +92,11 @@ export class WorkspaceUpgradeController { async (workspace, blobStorage) => { await upgradeV1ToV2(clonedDoc, workspace.doc); migrateGuidCompatibility(clonedDoc); - await forceUpgradePages( - workspace.doc, - this.blockSuiteWorkspace.schema - ); - const blobList = await this.blockSuiteWorkspace.blob.list(); + await forceUpgradePages(workspace.doc, this.docCollection.schema); + const blobList = await this.docCollection.blob.list(); for (const blobKey of blobList) { - const blob = await this.blockSuiteWorkspace.blob.get(blobKey); + const blob = await this.docCollection.blob.get(blobKey); if (blob) { await blobStorage.set(blobKey, blob); } @@ -110,13 +107,13 @@ export class WorkspaceUpgradeController { return newWorkspace; } else if (step === MigrationPoint.GuidFix) { migrateGuidCompatibility(clonedDoc); - await forceUpgradePages(clonedDoc, this.blockSuiteWorkspace.schema); - applyDoc(this.blockSuiteWorkspace.doc, clonedDoc); + await forceUpgradePages(clonedDoc, this.docCollection.schema); + applyDoc(this.docCollection.doc, clonedDoc); await this.sync.waitForSynced(); return null; } else if (step === MigrationPoint.BlockVersion) { - await forceUpgradePages(clonedDoc, this.blockSuiteWorkspace.schema); - applyDoc(this.blockSuiteWorkspace.doc, clonedDoc); + await forceUpgradePages(clonedDoc, this.docCollection.schema); + applyDoc(this.docCollection.doc, clonedDoc); await this.sync.waitForSynced(); return null; } else { diff --git a/packages/common/infra/src/workspace/workspace.ts b/packages/common/infra/src/workspace/workspace.ts index 2fa2c75303..ff43b94ce2 100644 --- a/packages/common/infra/src/workspace/workspace.ts +++ b/packages/common/infra/src/workspace/workspace.ts @@ -1,6 +1,6 @@ import { DebugLogger } from '@affine/debug'; import { Slot } from '@blocksuite/global/utils'; -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import type { ServiceProvider } from '../di'; import { CleanupService } from '../lifecycle'; @@ -10,7 +10,7 @@ import { type WorkspaceMetadata } from './metadata'; import type { WorkspaceUpgradeController } from './upgrade'; import { type WorkspaceUpgradeStatus } from './upgrade'; -export type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +export type { DocCollection } from '@blocksuite/store'; const logger = new DebugLogger('affine:workspace'); @@ -67,7 +67,7 @@ export class Workspace { constructor( public meta: WorkspaceMetadata, public engine: WorkspaceEngine, - public blockSuiteWorkspace: BlockSuiteWorkspace, + public docCollection: DocCollection, public upgrade: WorkspaceUpgradeController, public services: ServiceProvider ) { diff --git a/packages/common/y-indexeddb/package.json b/packages/common/y-indexeddb/package.json index 9f188f00ba..983968e34d 100644 --- a/packages/common/y-indexeddb/package.json +++ b/packages/common/y-indexeddb/package.json @@ -32,14 +32,14 @@ } }, "dependencies": { - "@blocksuite/global": "0.13.0-canary-202403050653-934469c", + "@blocksuite/global": "0.13.0-canary-202403140320-a2b362b", "idb": "^8.0.0", "nanoid": "^5.0.6", "y-provider": "workspace:*" }, "devDependencies": { - "@blocksuite/blocks": "0.13.0-canary-202403050653-934469c", - "@blocksuite/store": "0.13.0-canary-202403050653-934469c", + "@blocksuite/blocks": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/store": "0.13.0-canary-202403140320-a2b362b", "fake-indexeddb": "^5.0.2", "vite": "^5.1.4", "vite-plugin-dts": "3.7.3", diff --git a/packages/common/y-indexeddb/src/__tests__/index.spec.ts b/packages/common/y-indexeddb/src/__tests__/index.spec.ts index d514f2f419..a9dd400954 100644 --- a/packages/common/y-indexeddb/src/__tests__/index.spec.ts +++ b/packages/common/y-indexeddb/src/__tests__/index.spec.ts @@ -8,7 +8,7 @@ import { setTimeout } from 'node:timers/promises'; import { AffineSchemas } from '@blocksuite/blocks/schemas'; import { assertExists } from '@blocksuite/global/utils'; import type { Doc } from '@blocksuite/store'; -import { Schema, Workspace } from '@blocksuite/store'; +import { DocCollection, Schema } from '@blocksuite/store'; import { openDB } from 'idb'; import { nanoid } from 'nanoid'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -69,7 +69,7 @@ async function getUpdates(id: string): Promise { } let id: string; -let workspace: Workspace; +let docCollection: DocCollection; const rootDBName = DEFAULT_DB_NAME; const schema = new Schema(); @@ -78,7 +78,7 @@ schema.register(AffineSchemas); beforeEach(() => { id = nanoid(); - workspace = new Workspace({ + docCollection = new DocCollection({ id, schema, @@ -93,7 +93,7 @@ afterEach(() => { describe('indexeddb provider', () => { test('connect', async () => { - const provider = createIndexedDBProvider(workspace.doc); + const provider = createIndexedDBProvider(docCollection.doc); provider.connect(); // todo: has a better way to know when data is synced @@ -110,11 +110,11 @@ describe('indexeddb provider', () => { updates: [ { timestamp: expect.any(Number), - update: encodeStateAsUpdate(workspace.doc), + update: encodeStateAsUpdate(docCollection.doc), }, ], }); - const page = workspace.createDoc({ id: 'page0' }); + const page = docCollection.createDoc({ id: 'page0' }); page.load(); const pageBlockId = page.addBlock( 'affine:page' as keyof BlockSuite.BlockModels, @@ -139,13 +139,13 @@ describe('indexeddb provider', () => { const data = (await store.get(id)) as WorkspacePersist | undefined; assertExists(data); expect(data.id).toBe(id); - const testWorkspace = new Workspace({ + const testWorkspace = new DocCollection({ id: 'test', schema, }); // data should only contain updates for the root doc data.updates.forEach(({ update }) => { - Workspace.Y.applyUpdate(testWorkspace.doc, update); + DocCollection.Y.applyUpdate(testWorkspace.doc, update); }); const subPage = testWorkspace.doc.spaces.get('page0'); { @@ -157,23 +157,23 @@ describe('indexeddb provider', () => { assertExists(data); testWorkspace.getDoc('page0')?.load(); data.updates.forEach(({ update }) => { - Workspace.Y.applyUpdate(subPage, update); + DocCollection.Y.applyUpdate(subPage, update); }); } - expect(workspace.doc.toJSON()).toEqual(testWorkspace.doc.toJSON()); + expect(docCollection.doc.toJSON()).toEqual(testWorkspace.doc.toJSON()); } }); test('connect and disconnect', async () => { - const provider = createIndexedDBProvider(workspace.doc, rootDBName); + const provider = createIndexedDBProvider(docCollection.doc, rootDBName); provider.connect(); expect(provider.connected).toBe(true); await setTimeout(200); - const snapshot = encodeStateAsUpdate(workspace.doc); + const snapshot = encodeStateAsUpdate(docCollection.doc); provider.disconnect(); expect(provider.connected).toBe(false); { - const page = workspace.createDoc({ id: 'page0' }); + const page = docCollection.createDoc({ id: 'page0' }); page.load(); const pageBlockId = page.addBlock( 'affine:page' as keyof BlockSuite.BlockModels @@ -190,7 +190,7 @@ describe('indexeddb provider', () => { ); } { - const updates = await getUpdates(workspace.id); + const updates = await getUpdates(docCollection.id); expect(updates.length).toBe(1); expect(updates[0]).toEqual(snapshot); } @@ -199,7 +199,7 @@ describe('indexeddb provider', () => { expect(provider.connected).toBe(true); await setTimeout(200); { - const updates = await getUpdates(workspace.id); + const updates = await getUpdates(docCollection.id); expect(updates).not.toEqual([]); } expect(provider.connected).toBe(true); @@ -208,7 +208,7 @@ describe('indexeddb provider', () => { }); test('cleanup', async () => { - const provider = createIndexedDBProvider(workspace.doc); + const provider = createIndexedDBProvider(docCollection.doc); provider.connect(); await setTimeout(200); const db = await openDB(rootDBName, dbVersion); @@ -218,7 +218,7 @@ describe('indexeddb provider', () => { .transaction('workspace', 'readonly') .objectStore('workspace'); const keys = await store.getAllKeys(); - expect(keys).contain(workspace.id); + expect(keys).contain(docCollection.id); } await provider.cleanup(); @@ -229,16 +229,16 @@ describe('indexeddb provider', () => { .transaction('workspace', 'readonly') .objectStore('workspace'); const keys = await store.getAllKeys(); - expect(keys).not.contain(workspace.id); + expect(keys).not.contain(docCollection.id); } }); test('merge', async () => { setMergeCount(5); - const provider = createIndexedDBProvider(workspace.doc, rootDBName); + const provider = createIndexedDBProvider(docCollection.doc, rootDBName); provider.connect(); { - const page = workspace.createDoc({ id: 'page0' }); + const page = docCollection.createDoc({ id: 'page0' }); page.load(); const pageBlockId = page.addBlock( 'affine:page' as keyof BlockSuite.BlockModels @@ -264,7 +264,7 @@ describe('indexeddb provider', () => { }); test("data won't be lost", async () => { - const doc = new Workspace.Y.Doc(); + const doc = new DocCollection.Y.Doc(); const map = doc.getMap('map'); for (let i = 0; i < 100; i++) { map.set(`${i}`, i); @@ -275,7 +275,7 @@ describe('indexeddb provider', () => { provider.disconnect(); } { - const newDoc = new Workspace.Y.Doc(); + const newDoc = new DocCollection.Y.Doc(); const provider = createIndexedDBProvider(newDoc, rootDBName); provider.connect(); provider.disconnect(); @@ -412,14 +412,14 @@ describe('subDoc', () => { }); test('blocksuite', async () => { - const page0 = workspace.createDoc({ + const page0 = docCollection.createDoc({ id: 'page0', }); page0.load(); const { paragraphBlockId: paragraphBlockIdPage1 } = initEmptyPage(page0); - const provider = createIndexedDBProvider(workspace.doc, rootDBName); + const provider = createIndexedDBProvider(docCollection.doc, rootDBName); provider.connect(); - const page1 = workspace.createDoc({ + const page1 = docCollection.createDoc({ id: 'page1', }); page1.load(); @@ -427,22 +427,22 @@ describe('subDoc', () => { await setTimeout(200); provider.disconnect(); { - const newWorkspace = new Workspace({ + const docCollection = new DocCollection({ id, schema, }); - const provider = createIndexedDBProvider(newWorkspace.doc, rootDBName); + const provider = createIndexedDBProvider(docCollection.doc, rootDBName); provider.connect(); await setTimeout(200); - const page0 = newWorkspace.getDoc('page0') as Doc; + const page0 = docCollection.getDoc('page0') as Doc; page0.load(); await setTimeout(200); { const block = page0.getBlockById(paragraphBlockIdPage1); assertExists(block); } - const page1 = newWorkspace.getDoc('page1') as Doc; + const page1 = docCollection.getDoc('page1') as Doc; page1.load(); await setTimeout(200); { @@ -455,30 +455,30 @@ describe('subDoc', () => { describe('utils', () => { test('download binary', async () => { - const page = workspace.createDoc({ id: 'page0' }); + const page = docCollection.createDoc({ id: 'page0' }); page.load(); initEmptyPage(page); - const provider = createIndexedDBProvider(workspace.doc, rootDBName); + const provider = createIndexedDBProvider(docCollection.doc, rootDBName); provider.connect(); await setTimeout(200); provider.disconnect(); const update = (await downloadBinary( - workspace.id, + docCollection.id, rootDBName )) as Uint8Array; expect(update).toBeInstanceOf(Uint8Array); - const newWorkspace = new Workspace({ + const newDocCollection = new DocCollection({ id, schema, }); - applyUpdate(newWorkspace.doc, update); + applyUpdate(newDocCollection.doc, update); await setTimeout(); - expect(workspace.doc.toJSON()['meta']).toEqual( - newWorkspace.doc.toJSON()['meta'] + expect(docCollection.doc.toJSON()['meta']).toEqual( + newDocCollection.doc.toJSON()['meta'] ); - expect(Object.keys(workspace.doc.toJSON()['spaces'])).toEqual( - Object.keys(newWorkspace.doc.toJSON()['spaces']) + expect(Object.keys(docCollection.doc.toJSON()['spaces'])).toEqual( + Object.keys(newDocCollection.doc.toJSON()['spaces']) ); }); diff --git a/packages/common/y-provider/package.json b/packages/common/y-provider/package.json index 4152e56843..e730a0cf31 100644 --- a/packages/common/y-provider/package.json +++ b/packages/common/y-provider/package.json @@ -24,7 +24,7 @@ "build": "vite build" }, "devDependencies": { - "@blocksuite/store": "0.13.0-canary-202403050653-934469c", + "@blocksuite/store": "0.13.0-canary-202403140320-a2b362b", "vite": "^5.1.4", "vite-plugin-dts": "3.7.3", "vitest": "1.3.1", diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index df1e0e65f7..7ea7896843 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -58,7 +58,7 @@ "lottie-react": "^2.4.0", "lottie-web": "^5.12.2", "nanoid": "^5.0.6", - "next-themes": "^0.2.1", + "next-themes": "^0.3.0", "react": "18.2.0", "react-dom": "18.2.0", "react-error-boundary": "^4.0.12", @@ -72,12 +72,12 @@ "uuid": "^9.0.1" }, "devDependencies": { - "@blocksuite/blocks": "0.13.0-canary-202403050653-934469c", - "@blocksuite/global": "0.13.0-canary-202403050653-934469c", - "@blocksuite/icons": "2.1.44", - "@blocksuite/lit": "0.13.0-canary-202403050653-934469c", - "@blocksuite/presets": "0.13.0-canary-202403050653-934469c", - "@blocksuite/store": "0.13.0-canary-202403050653-934469c", + "@blocksuite/blocks": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/global": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/icons": "2.1.45", + "@blocksuite/lit": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/presets": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/store": "0.13.0-canary-202403140320-a2b362b", "@storybook/addon-actions": "^7.6.17", "@storybook/addon-essentials": "^7.6.17", "@storybook/addon-interactions": "^7.6.17", @@ -89,7 +89,7 @@ "@storybook/jest": "^0.2.3", "@storybook/react": "^7.6.17", "@storybook/react-vite": "^7.6.17", - "@storybook/test-runner": "^0.16.0", + "@storybook/test-runner": "^0.17.0", "@storybook/testing-library": "^0.2.2", "@testing-library/react": "^14.2.1", "@types/bytes": "^3.1.4", diff --git a/packages/frontend/component/src/components/auth-components/email-verified-email.tsx b/packages/frontend/component/src/components/auth-components/email-verified-email.tsx new file mode 100644 index 0000000000..bd49495333 --- /dev/null +++ b/packages/frontend/component/src/components/auth-components/email-verified-email.tsx @@ -0,0 +1,22 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import type { FC } from 'react'; + +import { Button } from '../../ui/button'; +import { AuthPageContainer } from './auth-page-container'; + +export const ConfirmChangeEmail: FC<{ + onOpenAffine: () => void; +}> = ({ onOpenAffine }) => { + const t = useAFFiNEI18N(); + + return ( + + + + ); +}; diff --git a/packages/frontend/component/src/components/auth-components/onboarding-page.tsx b/packages/frontend/component/src/components/auth-components/onboarding-page.tsx index 96fe6a8c29..05504f6a7c 100644 --- a/packages/frontend/component/src/components/auth-components/onboarding-page.tsx +++ b/packages/frontend/component/src/components/auth-components/onboarding-page.tsx @@ -37,7 +37,7 @@ function getCallbackUrl(location: Location) { try { const url = location.state?.callbackURL || - new URLSearchParams(location.search).get('callbackUrl'); + new URLSearchParams(location.search).get('redirect_uri'); if (typeof url === 'string' && url) { if (!url.startsWith('http:') && !url.startsWith('https:')) { return url; diff --git a/packages/frontend/component/src/components/auth-components/type.ts b/packages/frontend/component/src/components/auth-components/type.ts index d297b4bb4b..819bc607c0 100644 --- a/packages/frontend/component/src/components/auth-components/type.ts +++ b/packages/frontend/component/src/components/auth-components/type.ts @@ -3,4 +3,5 @@ export interface User { name: string; email: string; image?: string | null; + avatarUrl: string | null; } diff --git a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx index aad290e815..6506b64be9 100644 --- a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx +++ b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx @@ -4,6 +4,7 @@ import { SignOutIcon } from '@blocksuite/icons'; import { Avatar } from '../../ui/avatar'; import { Button, IconButton } from '../../ui/button'; import { Tooltip } from '../../ui/tooltip'; +import type { User } from '../auth-components'; import { NotFoundPattern } from './not-found-pattern'; import { largeButtonEffect, @@ -12,11 +13,7 @@ import { } from './styles.css'; export interface NotFoundPageProps { - user: { - name: string; - email: string; - avatar: string; - } | null; + user?: User | null; onBack: () => void; onSignOut: () => void; } @@ -47,7 +44,7 @@ export const NotFoundPage = ({ {user ? (

- + {user.email} diff --git a/packages/frontend/component/src/components/setting-components/workspace-detail-skeleton.tsx b/packages/frontend/component/src/components/setting-components/workspace-detail-skeleton.tsx index dde4dfd578..f376eaa5f2 100644 --- a/packages/frontend/component/src/components/setting-components/workspace-detail-skeleton.tsx +++ b/packages/frontend/component/src/components/setting-components/workspace-detail-skeleton.tsx @@ -8,19 +8,15 @@ export const WorkspaceDetailSkeleton = () => { <> } subtitle={} /> - {Array.from({ length: 3 }) - .fill(0) - .map((_, index) => { - return ( - } key={index}> - } - desc={} - spreadCol={false} - > - - ); - })} + {Array.from({ length: 3 }, (_, index) => ( + } key={index}> + } + desc={} + spreadCol={false} + > + + ))} ); }; diff --git a/packages/frontend/component/src/components/setting-components/workspace-list-skeleton.tsx b/packages/frontend/component/src/components/setting-components/workspace-list-skeleton.tsx index 8783ab82b0..a7e968dc5a 100644 --- a/packages/frontend/component/src/components/setting-components/workspace-list-skeleton.tsx +++ b/packages/frontend/component/src/components/setting-components/workspace-list-skeleton.tsx @@ -26,11 +26,9 @@ export const WorkspaceListItemSkeleton = () => { export const WorkspaceListSkeleton = () => { return ( <> - {Array.from({ length: 5 }) - .fill(0) - .map((_, index) => { - return ; - })} + {Array.from({ length: 5 }, (_, index) => ( + + ))} ); }; diff --git a/packages/frontend/component/src/ui/input/input.tsx b/packages/frontend/component/src/ui/input/input.tsx index 2bc95b4910..bb8ec5b0ec 100644 --- a/packages/frontend/component/src/ui/input/input.tsx +++ b/packages/frontend/component/src/ui/input/input.tsx @@ -2,7 +2,6 @@ import clsx from 'clsx'; import type { ChangeEvent, CSSProperties, - FocusEvent, FocusEventHandler, ForwardedRef, InputHTMLAttributes, @@ -10,7 +9,7 @@ import type { KeyboardEventHandler, ReactNode, } from 'react'; -import { forwardRef, useCallback, useState } from 'react'; +import { forwardRef, useCallback } from 'react'; import { input, inputWrapper } from './style.css'; @@ -39,8 +38,6 @@ export const Input = forwardRef(function Input( style = {}, inputStyle = {}, size = 'default', - onFocus, - onBlur, preFix, endFix, onEnter, @@ -50,8 +47,6 @@ export const Input = forwardRef(function Input( }: InputProps, ref: ForwardedRef ) { - const [isFocus, setIsFocus] = useState(false); - const handleAutoFocus = useCallback((ref: HTMLInputElement | null) => { if (ref) { window.setTimeout(() => ref.focus(), 0); @@ -64,7 +59,6 @@ export const Input = forwardRef(function Input( // status disabled: disabled, 'no-border': noBorder, - focus: isFocus, // color error: status === 'error', success: status === 'success', @@ -87,20 +81,6 @@ export const Input = forwardRef(function Input( ref={autoFocus ? handleAutoFocus : ref} disabled={disabled} style={inputStyle} - onFocus={useCallback( - (e: FocusEvent) => { - setIsFocus(true); - onFocus?.(e); - }, - [onFocus] - )} - onBlur={useCallback( - (e: FocusEvent) => { - setIsFocus(false); - onBlur?.(e); - }, - [onBlur] - )} onChange={useCallback( (e: ChangeEvent) => { propsOnChange?.(e.target.value); diff --git a/packages/frontend/component/src/ui/input/style.css.ts b/packages/frontend/component/src/ui/input/style.css.ts index 254e993ef8..f0e6dc37d7 100644 --- a/packages/frontend/component/src/ui/input/style.css.ts +++ b/packages/frontend/component/src/ui/input/style.css.ts @@ -42,8 +42,9 @@ export const inputWrapper = style({ '&.default': { borderColor: cssVar('borderColor'), }, - '&.default.focus': { + '&.default:is(:focus-within, :focus, :focus-visible)': { borderColor: cssVar('primaryColor'), + outline: 'none', boxShadow: '0px 0px 0px 2px rgba(30, 150, 235, 0.30);', }, }, diff --git a/packages/frontend/component/src/ui/modal/styles.css.ts b/packages/frontend/component/src/ui/modal/styles.css.ts index b3bbeb03d6..b3a8d44e3c 100644 --- a/packages/frontend/component/src/ui/modal/styles.css.ts +++ b/packages/frontend/component/src/ui/modal/styles.css.ts @@ -72,6 +72,7 @@ export const confirmModalContent = style({ marginBottom: '20px', height: '100%', overflowY: 'auto', + padding: '0 4px', }); export const confirmModalContainer = style({ display: 'flex', diff --git a/packages/frontend/core/.webpack/config.ts b/packages/frontend/core/.webpack/config.ts index af384252a6..4432eb69b5 100644 --- a/packages/frontend/core/.webpack/config.ts +++ b/packages/frontend/core/.webpack/config.ts @@ -384,6 +384,7 @@ export const createConfiguration: ( { context: '/api', target: 'http://localhost:3010' }, { context: '/socket.io', target: 'http://localhost:3010', ws: true }, { context: '/graphql', target: 'http://localhost:3010' }, + { context: '/oauth', target: 'http://localhost:3010' }, ], } as DevServerConfiguration, } satisfies webpack.Configuration; diff --git a/packages/frontend/core/.webpack/runtime-config.ts b/packages/frontend/core/.webpack/runtime-config.ts index a2f15f168d..201837f8be 100644 --- a/packages/frontend/core/.webpack/runtime-config.ts +++ b/packages/frontend/core/.webpack/runtime-config.ts @@ -1,16 +1,10 @@ -import type { BlockSuiteFeatureFlags, RuntimeConfig } from '@affine/env/global'; +import type { RuntimeConfig } from '@affine/env/global'; import type { BuildFlags } from '@affine/cli/config'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const packageJson = require('../package.json'); -const editorFlags: BlockSuiteFeatureFlags = { - enable_synced_doc_block: true, - enable_expand_database_block: false, - enable_bultin_ledits: false, -}; - export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { const buildPreset: Record = { stable: { @@ -35,7 +29,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enablePageHistory: true, allowLocalWorkspace: false, serverUrlPrefix: 'https://app.affine.pro', - editorFlags, appVersion: packageJson.version, editorVersion: packageJson.dependencies['@blocksuite/presets'], appBuildType: 'stable', @@ -78,7 +71,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enablePageHistory: true, allowLocalWorkspace: false, serverUrlPrefix: 'https://affine.fail', - editorFlags, appVersion: packageJson.version, editorVersion: packageJson.dependencies['@blocksuite/presets'], appBuildType: 'canary', diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index fc6ca09964..a88ad367fd 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -25,14 +25,14 @@ "@affine/i18n": "workspace:*", "@affine/templates": "workspace:*", "@affine/workspace-impl": "workspace:*", - "@blocksuite/block-std": "0.13.0-canary-202403050653-934469c", - "@blocksuite/blocks": "0.13.0-canary-202403050653-934469c", - "@blocksuite/global": "0.13.0-canary-202403050653-934469c", - "@blocksuite/icons": "2.1.44", - "@blocksuite/inline": "0.13.0-canary-202403050653-934469c", - "@blocksuite/lit": "0.13.0-canary-202403050653-934469c", - "@blocksuite/presets": "0.13.0-canary-202403050653-934469c", - "@blocksuite/store": "0.13.0-canary-202403050653-934469c", + "@blocksuite/block-std": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/blocks": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/global": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/icons": "2.1.45", + "@blocksuite/inline": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/lit": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/presets": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/store": "0.13.0-canary-202403140320-a2b362b", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -78,8 +78,7 @@ "lottie-web": "^5.12.2", "mini-css-extract-plugin": "^2.8.0", "nanoid": "^5.0.6", - "next-auth": "^4.24.5", - "next-themes": "^0.2.1", + "next-themes": "^0.3.0", "postcss-loader": "^8.1.0", "react": "18.2.0", "react-dom": "18.2.0", @@ -98,7 +97,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@aws-sdk/client-s3": "3.525.0", + "@aws-sdk/client-s3": "3.529.1", "@perfsee/webpack": "^1.12.2", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@sentry/webpack-plugin": "^2.14.2", diff --git a/packages/frontend/core/src/atoms/cloud-user.ts b/packages/frontend/core/src/atoms/cloud-user.ts deleted file mode 100644 index 2b43c0cf25..0000000000 --- a/packages/frontend/core/src/atoms/cloud-user.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { atom } from 'jotai'; -import type { SessionContextValue } from 'next-auth/react'; - -export const sessionAtom = atom | null>(null); diff --git a/packages/frontend/core/src/commands/affine-navigation.tsx b/packages/frontend/core/src/commands/affine-navigation.tsx index 5fabb25297..5b8d114c0e 100644 --- a/packages/frontend/core/src/commands/affine-navigation.tsx +++ b/packages/frontend/core/src/commands/affine-navigation.tsx @@ -1,7 +1,7 @@ import { WorkspaceSubPath } from '@affine/core/shared'; import type { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowRightBigIcon } from '@blocksuite/icons'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { registerAffineCommand } from '@toeverything/infra/command'; import type { createStore } from 'jotai'; @@ -11,13 +11,13 @@ import type { useNavigateHelper } from '../hooks/use-navigate-helper'; export function registerAffineNavigationCommands({ t, store, - workspace, + docCollection, navigationHelper, }: { t: ReturnType; store: ReturnType; navigationHelper: ReturnType; - workspace: Workspace; + docCollection: DocCollection; }) { const unsubs: Array<() => void> = []; unsubs.push( @@ -27,7 +27,7 @@ export function registerAffineNavigationCommands({ icon: , label: t['com.affine.cmdk.affine.navigation.goto-all-pages'](), run() { - navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); + navigationHelper.jumpToSubPath(docCollection.id, WorkspaceSubPath.ALL); }, }) ); @@ -39,7 +39,7 @@ export function registerAffineNavigationCommands({ icon: , label: 'Go to Collection List', run() { - navigationHelper.jumpToCollections(workspace.id); + navigationHelper.jumpToCollections(docCollection.id); }, }) ); @@ -51,7 +51,7 @@ export function registerAffineNavigationCommands({ icon: , label: 'Go to Tag List', run() { - navigationHelper.jumpToTags(workspace.id); + navigationHelper.jumpToTags(docCollection.id); }, }) ); @@ -91,7 +91,10 @@ export function registerAffineNavigationCommands({ icon: , label: t['com.affine.cmdk.affine.navigation.goto-trash'](), run() { - navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.TRASH); + navigationHelper.jumpToSubPath( + docCollection.id, + WorkspaceSubPath.TRASH + ); }, }) ); diff --git a/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx index 82d908102d..33a37d4824 100644 --- a/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx +++ b/packages/frontend/core/src/components/affine/affine-error-boundary/error-fallbacks/no-page-root-fallback.tsx @@ -1,4 +1,4 @@ -import { NoPageRootError } from '@affine/core/components/blocksuite/block-suite-editor'; +import { NoPageRootError } from '@affine/core/components/blocksuite/block-suite-editor/no-page-error'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ContactUS, ErrorDetail } from '../error-basic/error-detail'; diff --git a/packages/frontend/core/src/components/affine/app-container.tsx b/packages/frontend/core/src/components/affine/app-container.tsx index d680931ad9..563c753545 100644 --- a/packages/frontend/core/src/components/affine/app-container.tsx +++ b/packages/frontend/core/src/components/affine/app-container.tsx @@ -11,9 +11,7 @@ export const AppContainer = (props: WorkspaceRootProps) => { diff --git a/packages/frontend/core/src/components/affine/auth/index.tsx b/packages/frontend/core/src/components/affine/auth/index.tsx index 7a6660e285..fe86068d03 100644 --- a/packages/frontend/core/src/components/affine/auth/index.tsx +++ b/packages/frontend/core/src/components/affine/auth/index.tsx @@ -24,7 +24,7 @@ export type AuthProps = { setAuthEmail: (state: AuthProps['email']) => void; setEmailType: (state: AuthProps['emailType']) => void; email: string; - emailType: 'setPassword' | 'changePassword' | 'changeEmail'; + emailType: 'setPassword' | 'changePassword' | 'changeEmail' | 'verifyEmail'; onSignedIn?: () => void; }; @@ -59,8 +59,10 @@ export const AuthModal: FC = ({ emailType, }) => { const onSignedIn = useCallback(() => { + setAuthState('signIn'); + setAuthEmail(''); setOpen(false); - }, [setOpen]); + }, [setAuthState, setAuthEmail, setOpen]); return ( diff --git a/packages/frontend/core/src/components/affine/auth/oauth.tsx b/packages/frontend/core/src/components/affine/auth/oauth.tsx new file mode 100644 index 0000000000..2747383a9d --- /dev/null +++ b/packages/frontend/core/src/components/affine/auth/oauth.tsx @@ -0,0 +1,66 @@ +import { Button } from '@affine/component/ui/button'; +import { + useOAuthProviders, + useServerFeatures, +} from '@affine/core/hooks/affine/use-server-config'; +import { OAuthProviderType } from '@affine/graphql'; +import { GithubIcon, GoogleDuotoneIcon } from '@blocksuite/icons'; +import { type ReactElement, useCallback } from 'react'; + +import { useAuth } from './use-auth'; + +const OAuthProviderMap: Record< + OAuthProviderType, + { + icon: ReactElement; + } +> = { + [OAuthProviderType.Google]: { + icon: , + }, + + [OAuthProviderType.GitHub]: { + icon: , + }, +}; + +export function OAuth() { + const { oauth } = useServerFeatures(); + + if (!oauth) { + return null; + } + + return ; +} + +function OAuthProviders() { + const providers = useOAuthProviders(); + + return providers.map(provider => ( + + )); +} + +function OAuthProvider({ provider }: { provider: OAuthProviderType }) { + const { icon } = OAuthProviderMap[provider]; + const { oauthSignIn } = useAuth(); + + const onClick = useCallback(() => { + oauthSignIn(provider); + }, [provider, oauthSignIn]); + + return ( + + ); +} diff --git a/packages/frontend/core/src/components/affine/auth/send-email.tsx b/packages/frontend/core/src/components/affine/auth/send-email.tsx index 91b3a6330b..8e767c22dc 100644 --- a/packages/frontend/core/src/components/affine/auth/send-email.tsx +++ b/packages/frontend/core/src/components/affine/auth/send-email.tsx @@ -12,6 +12,7 @@ import { sendChangeEmailMutation, sendChangePasswordEmailMutation, sendSetPasswordEmailMutation, + sendVerifyEmailMutation, } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useSetAtom } from 'jotai/react'; @@ -29,7 +30,9 @@ const useEmailTitle = (emailType: AuthPanelProps['emailType']) => { case 'changePassword': return t['com.affine.auth.reset.password'](); case 'changeEmail': - return t['com.affine.settings.email.action'](); + return t['com.affine.settings.email.action.change'](); + case 'verifyEmail': + return t['com.affine.settings.email.action.verify'](); } }; const useContent = (emailType: AuthPanelProps['emailType'], email: string) => { @@ -41,7 +44,8 @@ const useContent = (emailType: AuthPanelProps['emailType'], email: string) => { case 'changePassword': return t['com.affine.auth.reset.password.message'](); case 'changeEmail': - return t['com.affine.auth.change.email.message']({ + case 'verifyEmail': + return t['com.affine.auth.verify.email.message']({ email, }); } @@ -56,7 +60,8 @@ const useNotificationHint = (emailType: AuthPanelProps['emailType']) => { case 'changePassword': return t['com.affine.auth.sent.change.password.hint'](); case 'changeEmail': - return t['com.affine.auth.sent.change.email.hint'](); + case 'verifyEmail': + return t['com.affine.auth.sent.verify.email.hint'](); } }; const useButtonContent = (emailType: AuthPanelProps['emailType']) => { @@ -68,7 +73,8 @@ const useButtonContent = (emailType: AuthPanelProps['emailType']) => { case 'changePassword': return t['com.affine.auth.send.reset.password.link'](); case 'changeEmail': - return t['com.affine.auth.send.change.email.link'](); + case 'verifyEmail': + return t['com.affine.auth.send.verify.email.hint'](); } }; @@ -87,12 +93,17 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => { useMutation({ mutation: sendChangeEmailMutation, }); + const { trigger: sendVerifyEmail, isMutating: isVerifyEmailMutation } = + useMutation({ + mutation: sendVerifyEmailMutation, + }); return { loading: isChangePasswordMutating || isSetPasswordMutating || - isChangeEmailMutating, + isChangeEmailMutating || + isVerifyEmailMutation, sendEmail: useCallback( (email: string) => { let trigger: (args: { @@ -113,6 +124,10 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => { trigger = sendChangeEmail; callbackUrl = 'changeEmail'; break; + case 'verifyEmail': + trigger = sendVerifyEmail; + callbackUrl = 'verify-email'; + break; } // TODO: add error handler return trigger({ @@ -127,6 +142,7 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => { sendChangeEmail, sendChangePasswordEmail, sendSetPasswordEmail, + sendVerifyEmail, ] ), }; diff --git a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx index bb649bd9d8..e5e6ff8515 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx @@ -5,10 +5,9 @@ import { ModalHeader, } from '@affine/component/auth-components'; import { Button } from '@affine/component/ui/button'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; import type { FC } from 'react'; import { useCallback, useState } from 'react'; @@ -25,7 +24,7 @@ export const SignInWithPassword: FC = ({ onSignedIn, }) => { const t = useAFFiNEI18N(); - const { update } = useSession(); + const { reload } = useSession(); const [password, setPassword] = useState(''); const [passwordError, setPasswordError] = useState(false); @@ -39,7 +38,6 @@ export const SignInWithPassword: FC = ({ const onSignIn = useAsyncCallback(async () => { const res = await signInCloud('credentials', { - redirect: false, email, password, }).catch(console.error); @@ -48,9 +46,9 @@ export const SignInWithPassword: FC = ({ return setPasswordError(true); } - await update(); + await reload(); onSignedIn?.(); - }, [email, password, onSignedIn, update]); + }, [email, password, onSignedIn, reload]); const sendMagicLink = useAsyncCallback(async () => { if (allowSendEmail && verifyToken && !sendingEmail) { diff --git a/packages/frontend/core/src/components/affine/auth/sign-in.tsx b/packages/frontend/core/src/components/affine/auth/sign-in.tsx index b53bcecc81..0d1a7a6a2f 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in.tsx @@ -12,7 +12,7 @@ import { } from '@affine/graphql'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons'; +import { ArrowDownBigIcon } from '@blocksuite/icons'; import { type FC, useState } from 'react'; import { useCallback } from 'react'; @@ -20,6 +20,7 @@ import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-s import { useMutation } from '../../../hooks/use-mutation'; import { emailRegex } from '../../../utils/email-regex'; import type { AuthPanelProps } from './index'; +import { OAuth } from './oauth'; import * as style from './style.css'; import { INTERNAL_BETA_URL, useAuth } from './use-auth'; import { Captcha, useCaptcha } from './use-captcha'; @@ -46,7 +47,6 @@ export const SignIn: FC = ({ allowSendEmail, signIn, signUp, - signInWithGoogle, } = useAuth(); const { trigger: verifyUser, isMutating } = useMutation({ @@ -59,6 +59,10 @@ export const SignIn: FC = ({ } const onContinue = useAsyncCallback(async () => { + if (!allowSendEmail) { + return; + } + if (!validateEmail(email)) { setIsValidEmail(false); return; @@ -99,13 +103,14 @@ export const SignIn: FC = ({ const res = await signUp(email, verifyToken, challenge); if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) { return setAuthState('noAccess'); - } else if (!res || res.status >= 400 || res.error) { + } else if (!res || res.status >= 400) { return; } setAuthState('afterSignUpSendEmail'); } } }, [ + allowSendEmail, subscriptionData, challenge, email, @@ -124,20 +129,7 @@ export const SignIn: FC = ({ subTitle={t['com.affine.brand.affineCloud']()} /> - +
void ) { - if (res?.error) { + if (!res?.ok) { pushNotification({ title: 'Send email error', message: 'Please back to home and try again', @@ -64,8 +64,13 @@ export const useAuth = () => { const [authStore, setAuthStore] = useAtom(authStoreAtom); const startResendCountDown = useSetAtom(countDownAtom); - const signIn = useCallback( - async (email: string, verifyToken: string, challenge?: string) => { + const sendEmailMagicLink = useCallback( + async ( + signUp: boolean, + email: string, + verifyToken: string, + challenge?: string + ) => { setAuthStore(prev => { return { ...prev, @@ -76,18 +81,19 @@ export const useAuth = () => { const res = await signInCloud( 'email', { - email: email, - callbackUrl: subscriptionData - ? subscriptionData.getRedirectUrl(false) - : '/auth/signIn', - redirect: false, + email, }, - challenge - ? { - challenge, - token: verifyToken, - } - : { token: verifyToken } + { + ...(challenge + ? { + challenge, + token: verifyToken, + } + : { token: verifyToken }), + callbackUrl: subscriptionData + ? subscriptionData.getRedirectUrl(signUp) + : '/auth/signIn', + } ).catch(console.error); handleSendEmailError(res, pushNotification); @@ -107,47 +113,24 @@ export const useAuth = () => { const signUp = useCallback( async (email: string, verifyToken: string, challenge?: string) => { - setAuthStore(prev => { - return { - ...prev, - isMutating: true, - }; - }); - - const res = await signInCloud( - 'email', - { - email: email, - callbackUrl: subscriptionData - ? subscriptionData.getRedirectUrl(true) - : '/auth/signUp', - redirect: false, - }, - challenge - ? { - challenge, - token: verifyToken, - } - : { token: verifyToken } - ).catch(console.error); - - handleSendEmailError(res, pushNotification); - - setAuthStore({ - isMutating: false, - allowSendEmail: false, - resendCountDown: COUNT_DOWN_TIME, - }); - - startResendCountDown(); - - return res; + return sendEmailMagicLink(true, email, verifyToken, challenge).catch( + console.error + ); }, - [pushNotification, setAuthStore, startResendCountDown, subscriptionData] + [sendEmailMagicLink] ); - const signInWithGoogle = useCallback(() => { - signInCloud('google').catch(console.error); + const signIn = useCallback( + async (email: string, verifyToken: string, challenge?: string) => { + return sendEmailMagicLink(false, email, verifyToken, challenge).catch( + console.error + ); + }, + [sendEmailMagicLink] + ); + + const oauthSignIn = useCallback((provider: OAuthProviderType) => { + signInCloud(provider).catch(console.error); }, []); const resetCountDown = useCallback(() => { @@ -165,6 +148,6 @@ export const useAuth = () => { isMutating: authStore.isMutating, signUp, signIn, - signInWithGoogle, + oauthSignIn, }; }; diff --git a/packages/frontend/core/src/components/affine/awareness/index.tsx b/packages/frontend/core/src/components/affine/awareness/index.tsx index f736203343..4c9d8df2f9 100644 --- a/packages/frontend/core/src/components/affine/awareness/index.tsx +++ b/packages/frontend/core/src/components/affine/awareness/index.tsx @@ -3,34 +3,34 @@ import { useLiveData } from '@toeverything/infra/livedata'; import { Suspense, useEffect } from 'react'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; -import { useCurrentUser } from '../../../hooks/affine/use-current-user'; +import { useSession } from '../../../hooks/affine/use-current-user'; import { CurrentWorkspaceService } from '../../../modules/workspace/current-workspace'; const SyncAwarenessInnerLoggedIn = () => { - const currentUser = useCurrentUser(); + const { user } = useSession(); const currentWorkspace = useLiveData( useService(CurrentWorkspaceService).currentWorkspace ); useEffect(() => { - if (currentUser && currentWorkspace) { - currentWorkspace.blockSuiteWorkspace.awarenessStore.awareness.setLocalStateField( + if (user && currentWorkspace) { + currentWorkspace.docCollection.awarenessStore.awareness.setLocalStateField( 'user', { - name: currentUser.name, + name: user.name, // todo: add avatar? } ); return () => { - currentWorkspace.blockSuiteWorkspace.awarenessStore.awareness.setLocalStateField( + currentWorkspace.docCollection.awarenessStore.awareness.setLocalStateField( 'user', null ); }; } return; - }, [currentUser, currentWorkspace]); + }, [user, currentWorkspace]); return null; }; diff --git a/packages/frontend/core/src/components/affine/page-history-modal/data.ts b/packages/frontend/core/src/components/affine/page-history-modal/data.ts index 3debe46c18..49935e1898 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/data.ts +++ b/packages/frontend/core/src/components/affine/page-history-modal/data.ts @@ -1,5 +1,5 @@ import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { useBlockSuiteWorkspacePage } from '@affine/core/hooks/use-block-suite-workspace-page'; +import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspace-page'; import { timestampToLocalDate } from '@affine/core/utils'; import { DebugLogger } from '@affine/debug'; import { @@ -10,7 +10,7 @@ import { } from '@affine/graphql'; import { AffineCloudBlobStorage } from '@affine/workspace-impl'; import { assertEquals } from '@blocksuite/global/utils'; -import { Workspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import { globalBlockSuiteSchema } from '@toeverything/infra'; import { revertUpdate } from '@toeverything/y-indexeddb'; import { useEffect, useMemo } from 'react'; @@ -98,14 +98,14 @@ const snapshotFetcher = async ( // so that we do not need to worry about providers etc // todo: fix references to the page (the referenced page will shown as deleted) // if we simply clone the current workspace, it maybe time consuming right? -const workspaceMap = new Map(); +const docCollectionMap = new Map(); // assume the workspace is a cloud workspace since the history feature is only enabled for cloud workspace const getOrCreateShellWorkspace = (workspaceId: string) => { - let workspace = workspaceMap.get(workspaceId); - if (!workspace) { + let docCollection = docCollectionMap.get(workspaceId); + if (!docCollection) { const blobStorage = new AffineCloudBlobStorage(workspaceId); - workspace = new Workspace({ + docCollection = new DocCollection({ id: workspaceId, blobStorages: [ () => ({ @@ -114,10 +114,10 @@ const getOrCreateShellWorkspace = (workspaceId: string) => { ], schema: globalBlockSuiteSchema, }); - workspaceMap.set(workspaceId, workspace); - workspace.doc.emit('sync', []); + docCollectionMap.set(workspaceId, docCollection); + docCollection.doc.emit('sync', []); } - return workspace; + return docCollection; }; // workspace id + page id + timestamp -> snapshot (update binary) @@ -139,17 +139,17 @@ export const usePageHistory = ( // workspace id + page id + timestamp + snapshot -> Page (to be used for rendering in blocksuite editor) export const useSnapshotPage = ( - workspace: Workspace, + docCollection: DocCollection, pageDocId: string, ts?: string ) => { - const snapshot = usePageHistory(workspace.id, pageDocId, ts); + const snapshot = usePageHistory(docCollection.id, pageDocId, ts); const page = useMemo(() => { if (!ts) { return; } const pageId = pageDocId + '-' + ts; - const historyShellWorkspace = getOrCreateShellWorkspace(workspace.id); + const historyShellWorkspace = getOrCreateShellWorkspace(docCollection.id); let page = historyShellWorkspace.getDoc(pageId); if (!page && snapshot) { page = historyShellWorkspace.createDoc({ @@ -163,15 +163,15 @@ export const useSnapshotPage = ( }); // must load before applyUpdate } return page ?? undefined; - }, [pageDocId, snapshot, ts, workspace]); + }, [pageDocId, snapshot, ts, docCollection]); useEffect(() => { - const historyShellWorkspace = getOrCreateShellWorkspace(workspace.id); + const historyShellWorkspace = getOrCreateShellWorkspace(docCollection.id); // apply the rootdoc's update to the current workspace // this makes sure the page reference links are not deleted ones in the preview - const update = encodeStateAsUpdate(workspace.doc); + const update = encodeStateAsUpdate(docCollection.doc); applyUpdate(historyShellWorkspace.doc, update); - }, [workspace]); + }, [docCollection]); return page; }; @@ -187,13 +187,16 @@ export const historyListGroupByDay = (histories: DocHistory[]) => { return [...map.entries()]; }; -export const useRestorePage = (workspace: Workspace, pageId: string) => { - const page = useBlockSuiteWorkspacePage(workspace, pageId); +export const useRestorePage = ( + docCollection: DocCollection, + pageId: string +) => { + const page = useDocCollectionPage(docCollection, pageId); const mutateQueryResource = useMutateQueryResource(); const { trigger: recover, isMutating } = useMutation({ mutation: recoverDocMutation, }); - const { getDocMeta, setDocTitle } = useDocMetaHelper(workspace); + const { getDocMeta, setDocTitle } = useDocMetaHelper(docCollection); const onRestore = useMemo(() => { return async (version: string, update: Uint8Array) => { @@ -216,12 +219,12 @@ export const useRestorePage = (workspace: Workspace, pageId: string) => { await recover({ docId: pageDocId, timestamp: version, - workspaceId: workspace.id, + workspaceId: docCollection.id, }); await mutateQueryResource(listHistoryQuery, vars => { return ( - vars.pageDocId === pageDocId && vars.workspaceId === workspace.id + vars.pageDocId === pageDocId && vars.workspaceId === docCollection.id ); }); @@ -234,7 +237,7 @@ export const useRestorePage = (workspace: Workspace, pageId: string) => { pageId, recover, setDocTitle, - workspace.id, + docCollection.id, ]); return { diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx index a620d88e43..87febfdaa4 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -5,13 +5,13 @@ import { ConfirmModal, Modal } from '@affine/component/ui/modal'; import { openSettingModalAtom } from '@affine/core/atoms'; import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; +import { useDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons'; import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; -import { type Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { type DocCollection } from '@blocksuite/store'; import * as Collapsible from '@radix-ui/react-collapsible'; import type { DialogContentProps } from '@radix-ui/react-dialog'; import { Doc, type PageMode, Workspace } from '@toeverything/infra'; @@ -49,7 +49,7 @@ import * as styles from './styles.css'; export interface PageHistoryModalProps { open: boolean; onOpenChange: (open: boolean) => void; - workspace: BlockSuiteWorkspace; + docCollection: DocCollection; pageId: string; } @@ -444,26 +444,26 @@ const EmptyHistoryPrompt = () => { }; const PageHistoryManager = ({ - workspace, + docCollection, pageId, onClose, }: { - workspace: BlockSuiteWorkspace; + docCollection: DocCollection; pageId: string; onClose: () => void; }) => { - const workspaceId = workspace.id; + const workspaceId = docCollection.id; const [activeVersion, setActiveVersion] = useState(); const pageDocId = useMemo(() => { - return workspace.getDoc(pageId)?.spaceDoc.guid ?? pageId; - }, [pageId, workspace]); + return docCollection.getDoc(pageId)?.spaceDoc.guid ?? pageId; + }, [pageId, docCollection]); - const snapshotPage = useSnapshotPage(workspace, pageDocId, activeVersion); + const snapshotPage = useSnapshotPage(docCollection, pageDocId, activeVersion); const t = useAFFiNEI18N(); - const { onRestore, isMutating } = useRestorePage(workspace, pageId); + const { onRestore, isMutating } = useRestorePage(docCollection, pageId); const handleRestore = useMemo( () => async () => { @@ -481,7 +481,7 @@ const PageHistoryManager = ({ const page = useService(Doc); const [mode, setMode] = useState(page.mode.value); - const title = useBlockSuiteWorkspacePageTitle(workspace, pageId); + const title = useDocCollectionPageTitle(docCollection, pageId); const [showRestoreConfirmModal, setShowRestoreConfirmModal] = useState(false); @@ -558,7 +558,7 @@ export const PageHistoryModal = ({ onOpenChange, open, pageId, - workspace, + docCollection: workspace, }: PageHistoryModalProps) => { const onClose = useCallback(() => { onOpenChange(false); @@ -570,7 +570,7 @@ export const PageHistoryModal = ({ @@ -595,7 +595,7 @@ export const GlobalPageHistoryModal = () => { open={open} onOpenChange={handleOpenChange} pageId={pageId} - workspace={workspace.blockSuiteWorkspace} + docCollection={workspace.docCollection} /> ); }; diff --git a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts index 3fc38b8de8..a856559a96 100644 --- a/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts +++ b/packages/frontend/core/src/components/affine/page-properties/page-properties-manager.ts @@ -124,7 +124,7 @@ export class PagePropertiesMetaManager { // returns page schema properties -> related page getPropertyStatistics() { const mapping = new Map>(); - for (const page of this.adapter.workspace.blockSuiteWorkspace.docs.values()) { + for (const page of this.adapter.workspace.docCollection.docs.values()) { const properties = this.adapter.getPageProperties(page.id); for (const id of Object.keys(properties.custom)) { if (!mapping.has(id)) mapping.set(id, new Set()); @@ -169,7 +169,7 @@ export class PagePropertiesManager { } get page() { - return this.adapter.workspace.blockSuiteWorkspace.getDoc(this.pageId); + return this.adapter.workspace.docCollection.getDoc(this.pageId); } get intrinsicMeta() { diff --git a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx index 674d719daf..42c15248e5 100644 --- a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx @@ -177,8 +177,8 @@ export const NumberValue = ({ property }: PropertyRowValueProps) => { export const TagsValue = () => { const workspace = useService(Workspace); const page = useService(Doc); - const blockSuiteWorkspace = workspace.blockSuiteWorkspace; - const pageMetas = useAllBlockSuiteDocMeta(blockSuiteWorkspace); + const docCollection = workspace.docCollection; + const pageMetas = useAllBlockSuiteDocMeta(docCollection); const legacyProperties = useService(WorkspaceLegacyProperties); const options = useLiveData(legacyProperties.tagOptions$); diff --git a/packages/frontend/core/src/components/affine/page-properties/styles.css.ts b/packages/frontend/core/src/components/affine/page-properties/styles.css.ts index 1d3be9aff8..2fe8b1a879 100644 --- a/packages/frontend/core/src/components/affine/page-properties/styles.css.ts +++ b/packages/frontend/core/src/components/affine/page-properties/styles.css.ts @@ -97,10 +97,10 @@ export const tableHeaderTimestamp = style({ }); export const tableHeaderDivider = style({ - height: '0.5px', + height: 0, + borderTop: `1px solid ${cssVar('borderColor')}`, width: '100%', margin: '8px 0', - backgroundColor: cssVar('borderColor'), }); export const tableBodyRoot = style({ diff --git a/packages/frontend/core/src/components/affine/page-properties/table.tsx b/packages/frontend/core/src/components/affine/page-properties/table.tsx index 3585d9a7b8..e58871aff0 100644 --- a/packages/frontend/core/src/components/affine/page-properties/table.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/table.tsx @@ -405,7 +405,7 @@ export const PageBacklinksPopup = ({ key={pageId} wrapper={MenuItem} pageId={pageId} - workspace={manager.workspace.blockSuiteWorkspace} + docCollection={manager.workspace.docCollection} /> ))}
@@ -600,7 +600,7 @@ export const PagePropertiesTableHeader = ({ const t = useAFFiNEI18N(); const backlinks = useBlockSuitePageBacklinks( - manager.workspace.blockSuiteWorkspace, + manager.workspace.docCollection, manager.pageId ); @@ -1015,7 +1015,7 @@ export const PagePropertiesAddProperty = () => { const PagePropertiesTableInner = () => { const manager = useContext(managerContext); const [expanded, setExpanded] = useState(false); - use(manager.workspace.blockSuiteWorkspace.doc.whenSynced); + use(manager.workspace.docCollection.doc.whenSynced); return (
{ // if the given page is not in the current workspace, then we don't render anything // eg. when it is in history modal - if (!manager.page) { + if (!manager.page || manager.readonly) { return null; } diff --git a/packages/frontend/core/src/components/affine/reference-link/index.tsx b/packages/frontend/core/src/components/affine/reference-link/index.tsx index d5dca25162..a6ae2bde61 100644 --- a/packages/frontend/core/src/components/affine/reference-link/index.tsx +++ b/packages/frontend/core/src/components/affine/reference-link/index.tsx @@ -1,10 +1,10 @@ import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; import { useJournalHelper } from '@affine/core/hooks/use-journal'; +import { WorkbenchLink } from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { LinkedPageIcon, TodayIcon } from '@blocksuite/icons'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import type { PropsWithChildren } from 'react'; -import { Link } from 'react-router-dom'; import * as styles from './styles.css'; @@ -44,15 +44,15 @@ export function pageReferenceRenderer({ export function AffinePageReference({ pageId, - workspace, + docCollection, wrapper: Wrapper, }: { - workspace: Workspace; + docCollection: DocCollection; pageId: string; wrapper?: React.ComponentType; }) { - const pageMetaHelper = useDocMetaHelper(workspace); - const journalHelper = useJournalHelper(workspace); + const pageMetaHelper = useDocMetaHelper(docCollection); + const journalHelper = useJournalHelper(docCollection); const t = useAFFiNEI18N(); const el = pageReferenceRenderer({ pageId, @@ -62,11 +62,8 @@ export function AffinePageReference({ }); return ( - + {Wrapper ? {el} : el} - + ); } diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index 287042d2af..b6a23a2aaa 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -13,6 +13,7 @@ import { allBlobSizesQuery, removeAvatarMutation, SubscriptionPlan, + updateUserProfileMutation, uploadAvatarMutation, } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -58,11 +59,10 @@ export const UserAvatar = () => { async (file: File) => { try { const reducedFile = await validateAndReduceImage(file); - await avatarTrigger({ + const data = await avatarTrigger({ avatar: reducedFile, // Pass the reducedFile directly to the avatarTrigger }); - // XXX: This is a hack to force the user to update, since next-auth can not only use update function without params - await user.update({ name: user.name }); + user.update({ avatarUrl: data.uploadAvatar.avatarUrl }); pushNotification({ title: 'Update user avatar success', type: 'success', @@ -82,8 +82,7 @@ export const UserAvatar = () => { async (e: MouseEvent) => { e.stopPropagation(); await removeAvatarTrigger(); - // XXX: This is a hack to force the user to update, since next-auth can not only use update function without params - user.update({ name: user.name }).catch(console.error); + user.update({ avatarUrl: null }); }, [removeAvatarTrigger, user] ); @@ -97,9 +96,9 @@ export const UserAvatar = () => { } - onRemove={user.image ? handleRemoveUserAvatar : undefined} + onRemove={user.avatarUrl ? handleRemoveUserAvatar : undefined} avatarTooltipOptions={{ content: t['Click to replace photo']() }} removeTooltipOptions={{ content: t['Remove photo']() }} data-testid="user-setting-avatar" @@ -115,14 +114,30 @@ export const AvatarAndName = () => { const t = useAFFiNEI18N(); const user = useCurrentUser(); const [input, setInput] = useState(user.name); + const pushNotification = useSetAtom(pushNotificationAtom); + const { trigger: updateProfile } = useMutation({ + mutation: updateUserProfileMutation, + }); const allowUpdate = !!input && input !== user.name; - const handleUpdateUserName = useCallback(() => { + const handleUpdateUserName = useAsyncCallback(async () => { if (!allowUpdate) { return; } - user.update({ name: input }).catch(console.error); - }, [allowUpdate, input, user]); + + try { + const data = await updateProfile({ + input: { name: input }, + }); + user.update({ name: data.updateProfile.name }); + } catch (e) { + pushNotification({ + title: 'Failed to update user name.', + message: String(e), + type: 'error', + }); + } + }, [allowUpdate, input, user, updateProfile, pushNotification]); return ( { openModal: true, state: 'sendEmail', email: user.email, - emailType: 'changeEmail', + emailType: user.emailVerified ? 'changeEmail' : 'verifyEmail', }); - }, [setAuthModal, user.email]); + }, [setAuthModal, user.email, user.emailVerified]); const onPasswordButtonClick = useCallback(() => { setAuthModal({ @@ -249,7 +264,9 @@ export const AccountSetting: FC = () => { { } /> - {environment.isMacOs && ( + { { } /> - )} + } ) : null} diff --git a/packages/frontend/core/src/components/affine/setting-modal/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/index.tsx index 68e1371202..bad3cc6713 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx @@ -1,3 +1,4 @@ +import { Loading } from '@affine/component'; import { WorkspaceDetailSkeleton } from '@affine/component/setting-components'; import { Modal, type ModalProps } from '@affine/component/ui/modal'; import { @@ -36,7 +37,15 @@ export interface SettingProps extends ModalProps { const isGeneralSetting = (key: string): key is GeneralSettingKey => GeneralSettingKeys.includes(key as GeneralSettingKey); -export const SettingModal = ({ +const CenteredLoading = () => { + return ( +
+ +
+ ); +}; + +const SettingModalInner = ({ activeTab = 'appearance', workspaceMetadata = null, onSettingClick, @@ -95,27 +104,12 @@ export const SettingModal = ({ }, [setOpenStarAFFiNEModal]); return ( - + <> -
+ + ); +}; + +export const SettingModal = ({ + activeTab = 'appearance', + workspaceMetadata = null, + onSettingClick, + ...modalProps +}: SettingProps) => { + return ( + + }> + + ); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx index a225416d63..0b3a2af2e4 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx @@ -5,11 +5,10 @@ import { import { Avatar } from '@affine/component/ui/avatar'; import { Tooltip } from '@affine/component/ui/tooltip'; import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner'; +import { useIsEarlyAccess } from '@affine/core/hooks/affine/use-user-features'; import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob'; -import { useWorkspaceAvailableFeatures } from '@affine/core/hooks/use-workspace-features'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; -import { WorkspaceFlavour } from '@affine/env/workspace'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { Logo1Icon } from '@blocksuite/icons'; import { @@ -49,7 +48,12 @@ export const UserInfo = ({ })} onClick={onAccountSettingClick} > - +
@@ -248,7 +252,7 @@ const WorkspaceListItem = ({ const isCurrent = currentWorkspace.id === meta.id; const t = useAFFiNEI18N(); const isOwner = useIsWorkspaceOwner(meta); - const availableFeatures = useWorkspaceAvailableFeatures(meta); + const isEarlyAccess = useIsEarlyAccess(); const onClickPreference = useCallback(() => { onClick('preference'); @@ -258,11 +262,7 @@ const WorkspaceListItem = ({ return subTabConfigs .filter(({ key }) => { if (key === 'experimental-features') { - return ( - isOwner && - currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD && - availableFeatures.length > 0 - ); + return isOwner && isEarlyAccess; } return true; }) @@ -282,14 +282,7 @@ const WorkspaceListItem = ({
); }); - }, [ - activeSubTab, - availableFeatures.length, - currentWorkspace.flavour, - isOwner, - onClick, - t, - ]); + }, [activeSubTab, isEarlyAccess, isOwner, onClick, t]); return ( <> diff --git a/packages/frontend/core/src/components/affine/setting-modal/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/style.css.ts index 57f98c750c..8b95a4e82a 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/style.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/style.css.ts @@ -51,3 +51,11 @@ export const link = style({ color: cssVar('linkColor'), cursor: 'pointer', }); + +export const centeredLoading = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + width: '100%', +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.css.ts b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.css.ts index 13291e0c87..c07da6ff9c 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.css.ts @@ -39,6 +39,13 @@ export const promptDisclaimer = style({ marginBottom: 32, gap: 4, }); + +export const settingsContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: 24, +}); + export const promptDisclaimerConfirm = style({ display: 'flex', justifyContent: 'center', @@ -47,9 +54,13 @@ export const switchRow = style({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - marginBottom: 32, }); export const switchDisabled = style({ opacity: 0.5, pointerEvents: 'none', }); +export const subHeader = style({ + fontWeight: '600', + color: cssVar('textSecondaryColor'), + marginBottom: 8, +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx index 07579c18f9..531aa28e12 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/experimental-features/index.tsx @@ -1,5 +1,6 @@ import { Button, Checkbox, Loading, Switch } from '@affine/component'; import { SettingHeader } from '@affine/component/setting-components'; +import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useSetWorkspaceFeature, @@ -79,6 +80,29 @@ interface ExperimentalFeaturesItemProps { } const ExperimentalFeaturesItem = ({ + title, + isMutating, + checked, + onChange, +}: { + title: React.ReactNode; + isMutating?: boolean; + checked: boolean; + onChange: (checked: boolean) => void; +}) => { + return ( +
+ {title} + +
+ ); +}; + +const WorkspaceFeaturesSettingItem = ({ feature, title, workspaceMetadata, @@ -96,14 +120,88 @@ const ExperimentalFeaturesItem = ({ ); return ( -
- {title} - -
+ + ); +}; + +const CopilotSettingRow = ({ + workspaceMetadata, +}: { + workspaceMetadata: WorkspaceMetadata; +}) => { + const features = useWorkspaceAvailableFeatures(workspaceMetadata); + + return features.includes(FeatureType.Copilot) ? ( + + ) : null; +}; + +const SplitViewSettingRow = () => { + const { appSettings, updateSettings } = useAppSettingHelper(); + + const onToggle = useCallback( + (checked: boolean) => { + updateSettings('enableMultiView', checked); + }, + [updateSettings] + ); + + if (!environment.isDesktop) { + return null; // only enable on desktop + } + + return ( + + ); +}; + +// feature flag -> display name +const blocksuiteFeatureFlags: Partial> = { + enable_synced_doc_block: 'Enable Synced Doc Block', + enable_expand_database_block: 'Enable Expand Database Block', + enable_bultin_ledits: 'Edit with LEDITS', +}; + +const BlocksuiteFeatureFlagSettings = () => { + const { appSettings, updateSettings } = useAppSettingHelper(); + const toggleSetting = useCallback( + (flag: keyof BlockSuiteFlags, checked: boolean) => { + updateSettings('editorFlags', { + ...appSettings.editorFlags, + [flag]: checked, + }); + }, + [appSettings.editorFlags, updateSettings] + ); + + type EditorFlag = keyof typeof appSettings.editorFlags; + + return ( + <> + {Object.entries(blocksuiteFeatureFlags).map(([flag, displayName]) => ( + + toggleSetting(flag as keyof BlockSuiteFlags, checked) + } + /> + ))} + ); }; @@ -113,7 +211,6 @@ const ExperimentalFeaturesMain = ({ workspaceMetadata: WorkspaceMetadata; }) => { const t = useAFFiNEI18N(); - const features = useWorkspaceAvailableFeatures(workspaceMetadata); return ( <> @@ -122,14 +219,11 @@ const ExperimentalFeaturesMain = ({ 'com.affine.settings.workspace.experimental-features.header.plugins' ]()} /> - - {features.includes(FeatureType.Copilot) ? ( - - ) : null} +
+ + + +
); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx index 02a217ef90..6da98db3e0 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx @@ -46,17 +46,13 @@ export const ProfilePanel = ({ isOwner, workspace }: ProfilePanelProps) => { const avatarUrl = useWorkspaceBlobObjectUrl(workspace?.meta, avatarBlob); useEffect(() => { - if (workspace?.blockSuiteWorkspace) { - setAvatarBlob(workspace.blockSuiteWorkspace.meta.avatar ?? null); - setName( - workspace.blockSuiteWorkspace.meta.name ?? UNTITLED_WORKSPACE_NAME - ); - const dispose = workspace.blockSuiteWorkspace.meta.commonFieldsUpdated.on( + if (workspace?.docCollection) { + setAvatarBlob(workspace.docCollection.meta.avatar ?? null); + setName(workspace.docCollection.meta.name ?? UNTITLED_WORKSPACE_NAME); + const dispose = workspace.docCollection.meta.commonFieldsUpdated.on( () => { - setAvatarBlob(workspace.blockSuiteWorkspace.meta.avatar ?? null); - setName( - workspace.blockSuiteWorkspace.meta.name ?? UNTITLED_WORKSPACE_NAME - ); + setAvatarBlob(workspace.docCollection.meta.avatar ?? null); + setName(workspace.docCollection.meta.name ?? UNTITLED_WORKSPACE_NAME); } ); return () => { @@ -75,14 +71,14 @@ export const ProfilePanel = ({ isOwner, workspace }: ProfilePanelProps) => { return; } if (!file) { - workspace.blockSuiteWorkspace.meta.setAvatar(''); + workspace.docCollection.meta.setAvatar(''); return; } try { const reducedFile = await validateAndReduceImage(file); - const blobs = workspace.blockSuiteWorkspace.blob; + const blobs = workspace.docCollection.blob; const blobId = await blobs.set(reducedFile); - workspace.blockSuiteWorkspace.meta.setAvatar(blobId); + workspace.docCollection.meta.setAvatar(blobId); } catch (error) { console.error(error); throw error; @@ -96,7 +92,7 @@ export const ProfilePanel = ({ isOwner, workspace }: ProfilePanelProps) => { if (!workspace) { return; } - workspace.blockSuiteWorkspace.meta.setName(name); + workspace.docCollection.meta.setName(name); }, [workspace] ); diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/properties/styles.css.ts b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/properties/styles.css.ts index ddeb7ba359..ba066f97ef 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/properties/styles.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/properties/styles.css.ts @@ -65,8 +65,8 @@ export const propertyDocCount = style({ export const divider = style({ width: '100%', - height: 1, - backgroundColor: cssVar('dividerColor'), + height: 0, + borderTop: `1px solid ${cssVar('borderColor')}`, }); export const spacer = style({ diff --git a/packages/frontend/core/src/components/app-sidebar/sidebar-header/sidebar-switch.css.ts b/packages/frontend/core/src/components/app-sidebar/sidebar-header/sidebar-switch.css.ts index ee6d809f59..45c075d008 100644 --- a/packages/frontend/core/src/components/app-sidebar/sidebar-header/sidebar-switch.css.ts +++ b/packages/frontend/core/src/components/app-sidebar/sidebar-header/sidebar-switch.css.ts @@ -1,18 +1,23 @@ import { style } from '@vanilla-extract/css'; + export const sidebarSwitch = style({ opacity: 0, - display: 'none !important', + display: 'inline-flex', overflow: 'hidden', pointerEvents: 'none', - transition: 'all .3s ease-in-out', + transition: 'max-width 0.2s ease-in-out, margin 0.3s ease-in-out', selectors: { '&[data-show=true]': { + maxWidth: '32px', opacity: 1, - display: 'inline-flex !important', width: '32px', flexShrink: 0, fontSize: '24px', pointerEvents: 'auto', }, + '&[data-show=false]': { + maxWidth: 0, + margin: '0 !important', + }, }, }); diff --git a/packages/frontend/core/src/components/app-sidebar/sidebar-header/sidebar-switch.tsx b/packages/frontend/core/src/components/app-sidebar/sidebar-header/sidebar-switch.tsx index 818c9419f3..70e584d9ed 100644 --- a/packages/frontend/core/src/components/app-sidebar/sidebar-header/sidebar-switch.tsx +++ b/packages/frontend/core/src/components/app-sidebar/sidebar-header/sidebar-switch.tsx @@ -8,10 +8,10 @@ import { appSidebarOpenAtom } from '../index.jotai'; import * as styles from './sidebar-switch.css'; export const SidebarSwitch = ({ - show = true, + show, className, }: { - show?: boolean; + show: boolean; className?: string; }) => { const [open, setOpen] = useAtom(appSidebarOpenAtom); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx index 38d209f27d..e0af6cd8e4 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/blocksuite-editor.tsx @@ -16,13 +16,13 @@ import { useMemo, useRef, } from 'react'; -import { type Map as YMap } from 'yjs'; import { pageReferenceRenderer, type PageReferenceRendererOptions, } from '../../affine/reference-link'; import { BlocksuiteEditorContainer } from './blocksuite-editor-container'; +import { NoPageRootError } from './no-page-error'; import type { InlineRenderers } from './specs'; export type ErrorBoundaryProps = { @@ -77,34 +77,6 @@ const customRenderersFactory: ( }, }); -/** - * TODO: Define error to unexpected state together in the future. - */ -export class NoPageRootError extends Error { - constructor(public page: Doc) { - super('Page root not found when render editor!'); - - // Log info to let sentry collect more message - const hasExpectSpace = Array.from(page.rootDoc.spaces.values()).some( - doc => page.spaceDoc.guid === doc.guid - ); - const blocks = page.spaceDoc.getMap('blocks') as YMap>; - const havePageBlock = Array.from(blocks.values()).some( - block => block.get('sys:flavour') === 'affine:page' - ); - console.info( - 'NoPageRootError current data: %s', - JSON.stringify({ - expectPageId: page.id, - expectGuid: page.spaceDoc.guid, - hasExpectSpace, - blockSize: blocks.size, - havePageBlock, - }) - ); - } -} - const BlockSuiteEditorImpl = forwardRef( function BlockSuiteEditorImpl( { mode, page, className, defaultSelectedBlockId, onLoadEditor, style }, @@ -138,8 +110,8 @@ const BlockSuiteEditorImpl = forwardRef( }; }, []); - const pageMetaHelper = useDocMetaHelper(page.workspace); - const journalHelper = useJournalHelper(page.workspace); + const pageMetaHelper = useDocMetaHelper(page.collection); + const journalHelper = useJournalHelper(page.collection); const t = useAFFiNEI18N(); const customRenderers = useMemo(() => { diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx index aca27a2463..c6e08bf60d 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/journal-doc-title.tsx @@ -6,7 +6,7 @@ import * as styles from './styles.css'; export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Doc }) => { const { localizedJournalDate, isTodayJournal, journalDate } = - useJournalInfoHelper(page.workspace, page.id); + useJournalInfoHelper(page.collection, page.id); const t = useAFFiNEI18N(); // TODO: i18n diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index ee0ee6dd64..0a38a1c57b 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -8,7 +8,6 @@ import { PageEditor, } from '@blocksuite/presets'; import { type Doc } from '@blocksuite/store'; -import clsx from 'clsx'; import React, { forwardRef, useCallback, @@ -65,7 +64,7 @@ export const BlocksuiteDocEditor = forwardRef< const docRef = useRef(null); const [docPage, setDocPage] = useState(); - const { isJournal } = useJournalInfoHelper(page.workspace, page.id); + const { isJournal } = useJournalInfoHelper(page.collection, page.id); const onDocRef = useCallback( (el: PageEditor) => { @@ -103,10 +102,7 @@ export const BlocksuiteDocEditor = forwardRef< return (
-
+
{!isJournal ? ( ) : ( @@ -128,7 +124,7 @@ export const BlocksuiteDocEditor = forwardRef< }} >
) : null} - {docPage ? ( + {docPage && !page.readonly ? ( ) : null}
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/no-page-error.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/no-page-error.ts new file mode 100644 index 0000000000..40d216e70f --- /dev/null +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/no-page-error.ts @@ -0,0 +1,30 @@ +import type { Doc } from '@blocksuite/store'; +import { type Map as YMap } from 'yjs'; + +/** + * TODO: Define error to unexpected state together in the future. + */ +export class NoPageRootError extends Error { + constructor(public page: Doc) { + super('Page root not found when render editor!'); + + // Log info to let sentry collect more message + const hasExpectSpace = Array.from(page.rootDoc.spaces.values()).some( + doc => page.spaceDoc.guid === doc.guid + ); + const blocks = page.spaceDoc.getMap('blocks') as YMap>; + const havePageBlock = Array.from(blocks.values()).some( + block => block.get('sys:flavour') === 'affine:page' + ); + console.info( + 'NoPageRootError current data: %s', + JSON.stringify({ + expectPageId: page.id, + expectGuid: page.spaceDoc.guid, + hasExpectSpace, + blockSize: blocks.size, + havePageBlock, + }) + ); + } +} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts index 406b88f8de..11e85974ec 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/styles.css.ts @@ -5,28 +5,10 @@ export const docEditorRoot = style({ background: cssVar('backgroundPrimaryColor'), }); -// brings styles of .affine-page-viewport from blocksuite export const affineDocViewport = style({ display: 'flex', flexDirection: 'column', - userSelect: 'none', - containerName: 'viewport', - // todo: find out what this does in bs - containerType: 'inline-size', - background: cssVar('backgroundPrimaryColor'), - '@media': { - print: { - display: 'none', - zIndex: -1, - }, - }, - selectors: { - '&[data-doc-viewport="true"]': { - overflowX: 'visible', - overflowY: 'visible', - height: 'auto', - }, - }, + paddingBottom: '150px', }); export const docContainer = style({ diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx index 51094b88fb..60b08fe1c8 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/favorite/index.tsx @@ -15,17 +15,17 @@ export interface FavoriteButtonProps { export const useFavorite = (pageId: string) => { const t = useAFFiNEI18N(); const workspace = useService(Workspace); - const blockSuiteWorkspace = workspace.blockSuiteWorkspace; - const currentPage = blockSuiteWorkspace.getDoc(pageId); + const docCollection = workspace.docCollection; + const currentPage = docCollection.getDoc(pageId); assertExists(currentPage); - const pageMeta = useBlockSuiteDocMeta(blockSuiteWorkspace).find( + const pageMeta = useBlockSuiteDocMeta(docCollection).find( meta => meta.id === pageId ); const favorite = pageMeta?.favorite ?? false; const { toggleFavorite: _toggleFavorite } = - useBlockSuiteMetaHelper(blockSuiteWorkspace); + useBlockSuiteMetaHelper(docCollection); const toggleFavorite = useCallback(() => { _toggleFavorite(pageId); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/date-picker.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/date-picker.tsx index bc9eed453d..eea681b36f 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/date-picker.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/date-picker.tsx @@ -3,24 +3,24 @@ import { useJournalInfoHelper, useJournalRouteHelper, } from '@affine/core/hooks/use-journal'; -import type { BlockSuiteWorkspace } from '@affine/core/shared'; +import type { DocCollection } from '@affine/core/shared'; import type { Doc } from '@blocksuite/store'; import dayjs from 'dayjs'; import { useEffect, useRef, useState } from 'react'; export interface JournalWeekDatePickerProps { - workspace: BlockSuiteWorkspace; + docCollection: DocCollection; page: Doc; } const weekStyle = { maxWidth: 800, width: '100%' }; export const JournalWeekDatePicker = ({ - workspace, + docCollection, page, }: JournalWeekDatePickerProps) => { const handleRef = useRef(null); - const { journalDate } = useJournalInfoHelper(workspace, page.id); - const { openJournal } = useJournalRouteHelper(workspace); + const { journalDate } = useJournalInfoHelper(docCollection, page.id); + const { openJournal } = useJournalRouteHelper(docCollection); const [date, setDate] = useState( (journalDate ?? dayjs()).format('YYYY-MM-DD') ); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/today-button.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/today-button.tsx index b6e3c21870..4ac47c1a1c 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/today-button.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/journal/today-button.tsx @@ -1,16 +1,18 @@ import { Button } from '@affine/component'; import { useJournalRouteHelper } from '@affine/core/hooks/use-journal'; -import type { BlockSuiteWorkspace } from '@affine/core/shared'; +import type { DocCollection } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useCallback } from 'react'; export interface JournalTodayButtonProps { - workspace: BlockSuiteWorkspace; + docCollection: DocCollection; } -export const JournalTodayButton = ({ workspace }: JournalTodayButtonProps) => { +export const JournalTodayButton = ({ + docCollection, +}: JournalTodayButtonProps) => { const t = useAFFiNEI18N(); - const journalHelper = useJournalRouteHelper(workspace); + const journalHelper = useJournalRouteHelper(docCollection); const onToday = useCallback(() => { journalHelper.openToday(); diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx index de507462e2..d7568104e9 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx @@ -47,11 +47,11 @@ export const PageHeaderMenuButton = ({ const t = useAFFiNEI18N(); const workspace = useService(Workspace); - const blockSuiteWorkspace = workspace.blockSuiteWorkspace; - const currentPage = blockSuiteWorkspace.getDoc(pageId); + const docCollection = workspace.docCollection; + const currentPage = docCollection.getDoc(pageId); assertExists(currentPage); - const pageMeta = useBlockSuiteDocMeta(blockSuiteWorkspace).find( + const pageMeta = useBlockSuiteDocMeta(docCollection).find( meta => meta.id === pageId ); const page = useService(Doc); @@ -59,9 +59,9 @@ export const PageHeaderMenuButton = ({ const { favorite, toggleFavorite } = useFavorite(pageId); - const { duplicate } = useBlockSuiteMetaHelper(blockSuiteWorkspace); - const { importFile } = usePageHelper(blockSuiteWorkspace); - const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace); + const { duplicate } = useBlockSuiteMetaHelper(docCollection); + const { importFile } = usePageHelper(docCollection); + const { setTrashModal } = useTrashModalHelper(docCollection); const [historyModalOpen, setHistoryModalOpen] = useState(false); const setOpenHistoryTipsModal = useSetAtom(openHistoryTipsModalAtom); @@ -227,7 +227,7 @@ export const PageHeaderMenuButton = ({ {workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? ( ; export const BlocksuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => { - const { - blockSuiteWorkspace: workspace, - pageId, - isPublic, - inputHandleRef, - } = props; - const currentPage = workspace.getDoc(pageId); - const pageMeta = useBlockSuiteDocMeta(workspace).find( + const { docCollection, pageId, isPublic, inputHandleRef } = props; + const currentPage = docCollection.getDoc(pageId); + const pageMeta = useBlockSuiteDocMeta(docCollection).find( meta => meta.id === currentPage?.id ); const title = pageMeta?.title; - const { setDocTitle } = useDocMetaHelper(workspace); + const { setDocTitle } = useDocMetaHelper(docCollection); const onChange = useCallback( (v: string) => { diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx index fd26ba350a..6f22e8eacc 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx @@ -10,14 +10,14 @@ import { import type { CSSProperties } from 'react'; import { useCallback, useEffect } from 'react'; -import type { BlockSuiteWorkspace } from '../../../shared'; +import type { DocCollection } from '../../../shared'; import { toast } from '../../../utils'; import { StyledEditorModeSwitch, StyledKeyboardItem } from './style'; import { EdgelessSwitchItem, PageSwitchItem } from './switch-items'; export type EditorModeSwitchProps = { // todo(himself65): combine these two properties - blockSuiteWorkspace: BlockSuiteWorkspace; + docCollection: DocCollection; pageId: string; style?: CSSProperties; isPublic?: boolean; @@ -36,13 +36,13 @@ const TooltipContent = () => { }; export const EditorModeSwitch = ({ style, - blockSuiteWorkspace, + docCollection, pageId, isPublic, publicMode, }: EditorModeSwitchProps) => { const t = useAFFiNEI18N(); - const pageMeta = useBlockSuiteDocMeta(blockSuiteWorkspace).find( + const pageMeta = useBlockSuiteDocMeta(docCollection).find( meta => meta.id === pageId ); const trash = pageMeta?.trash ?? false; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx index 6817535ee9..afed76738d 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-page-list/utils.tsx @@ -1,7 +1,7 @@ import { toast } from '@affine/component'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { useBlockSuiteWorkspaceHelper } from '@affine/core/hooks/use-block-suite-workspace-helper'; +import { useDocCollectionHelper } from '@affine/core/hooks/use-block-suite-workspace-helper'; import { WorkspaceSubPath } from '@affine/core/shared'; import { useService } from '@toeverything/infra'; import { PageRecordList } from '@toeverything/infra'; @@ -9,12 +9,12 @@ import { initEmptyPage } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; -import type { BlockSuiteWorkspace } from '../../../shared'; +import type { DocCollection } from '../../../shared'; -export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => { +export const usePageHelper = (docCollection: DocCollection) => { const { openPage, jumpToSubPath } = useNavigateHelper(); - const { createDoc } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace); - const { setDocMeta } = useDocMetaHelper(blockSuiteWorkspace); + const { createDoc } = useDocCollectionHelper(docCollection); + const { setDocMeta } = useDocMetaHelper(docCollection); const pageRecordList = useService(PageRecordList); const isPreferredEdgeless = useCallback( @@ -28,10 +28,10 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => { const page = createDoc(); initEmptyPage(page); pageRecordList.record(page.id).value?.setMode(mode || 'page'); - openPage(blockSuiteWorkspace.id, page.id); + openPage(docCollection.id, page.id); return page; }, - [blockSuiteWorkspace.id, createDoc, openPage, pageRecordList] + [docCollection.id, createDoc, openPage, pageRecordList] ); const createEdgelessAndOpen = useCallback(() => { @@ -50,7 +50,7 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => { }.` ); if (options.isWorkspaceFile) { - jumpToSubPath(blockSuiteWorkspace.id, WorkspaceSubPath.ALL); + jumpToSubPath(docCollection.id, WorkspaceSubPath.ALL); return; } @@ -58,16 +58,16 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => { return; } const pageId = pageIds[0]; - openPage(blockSuiteWorkspace.id, pageId); + openPage(docCollection.id, pageId); }; - showImportModal({ workspace: blockSuiteWorkspace, onSuccess }); - }, [blockSuiteWorkspace, openPage, jumpToSubPath]); + showImportModal({ collection: docCollection, onSuccess }); + }, [docCollection, openPage, jumpToSubPath]); const createLinkedPageAndOpen = useAsyncCallback( async (pageId: string) => { const page = createPageAndOpen(); page.load(); - const parentPage = blockSuiteWorkspace.getDoc(pageId); + const parentPage = docCollection.getDoc(pageId); if (parentPage) { parentPage.load(); const text = parentPage.Text.fromDelta([ @@ -86,7 +86,7 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => { setDocMeta(page.id, {}); } }, - [blockSuiteWorkspace, createPageAndOpen, setDocMeta] + [docCollection, createPageAndOpen, setDocMeta] ); return useMemo(() => { diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx index b9ad390902..0b4b313b53 100644 --- a/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/user-avatar.tsx @@ -26,7 +26,7 @@ const UserInfo = () => { @@ -51,7 +51,7 @@ export const PublishPageUserAvatar = () => { const location = useLocation(); const handleSignOut = useAsyncCallback(async () => { - await signOutCloud({ callbackUrl: location.pathname }); + await signOutCloud(location.pathname); }, [location.pathname]); const menuItem = useMemo(() => { @@ -84,7 +84,7 @@ export const PublishPageUserAvatar = () => { }} >
- +
); diff --git a/packages/frontend/core/src/components/image-preview/index.tsx b/packages/frontend/core/src/components/image-preview/index.tsx index 3c9aca8e91..4c8c095b5d 100644 --- a/packages/frontend/core/src/components/image-preview/index.tsx +++ b/packages/frontend/core/src/components/image-preview/index.tsx @@ -13,7 +13,7 @@ import { PlusIcon, ViewBarIcon, } from '@blocksuite/icons'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import clsx from 'clsx'; import { useErrorBoundary } from 'foxact/use-error-boundary'; import { useAtom } from 'jotai'; @@ -45,7 +45,7 @@ import { import { hasAnimationPlayedAtom, previewBlockIdAtom } from './index.jotai'; export type ImagePreviewModalProps = { - workspace: Workspace; + docCollection: DocCollection; pageId: string; }; @@ -92,7 +92,7 @@ const ImagePreviewModalImpl = ( const nextImageHandler = useCallback( (blockId: string | null) => { assertExists(blockId); - const workspace = props.workspace; + const workspace = props.docCollection; if (!hasPlayedAnimation) { setHasPlayedAnimation(true); } @@ -109,13 +109,13 @@ const ImagePreviewModalImpl = ( setBlockId(nextBlock.id); } }, - [props.pageId, props.workspace, setBlockId, hasPlayedAnimation] + [props.pageId, props.docCollection, setBlockId, hasPlayedAnimation] ); const previousImageHandler = useCallback( (blockId: string | null) => { assertExists(blockId); - const workspace = props.workspace; + const workspace = props.docCollection; const page = workspace.getDoc(props.pageId); assertExists(page); const block = page.getBlockById(blockId); @@ -130,12 +130,12 @@ const ImagePreviewModalImpl = ( } resetZoom(); }, - [props.pageId, props.workspace, setBlockId, resetZoom] + [props.pageId, props.docCollection, setBlockId, resetZoom] ); const deleteHandler = useCallback( (blockId: string) => { - const { pageId, workspace, onClose } = props; + const { pageId, docCollection: workspace, onClose } = props; const page = workspace.getDoc(pageId); assertExists(page); @@ -185,7 +185,7 @@ const ImagePreviewModalImpl = ( const downloadHandler = useCallback( async (blockId: string | null) => { - const workspace = props.workspace; + const workspace = props.docCollection; const page = workspace.getDoc(props.pageId); assertExists(page); if (typeof blockId === 'string') { @@ -237,31 +237,31 @@ const ImagePreviewModalImpl = ( a.remove(); } }, - [props.pageId, props.workspace] + [props.pageId, props.docCollection] ); const [caption, setCaption] = useState(() => { - const page = props.workspace.getDoc(props.pageId); + const page = props.docCollection.getDoc(props.pageId); assertExists(page); const block = page.getBlockById(props.blockId) as ImageBlockModel; assertExists(block); return block?.caption; }); useEffect(() => { - const page = props.workspace.getDoc(props.pageId); + const page = props.docCollection.getDoc(props.pageId); assertExists(page); const block = page.getBlockById(props.blockId) as ImageBlockModel; assertExists(block); setCaption(block?.caption); - }, [props.blockId, props.pageId, props.workspace]); + }, [props.blockId, props.pageId, props.docCollection]); const { data, error } = useSWR( ['workspace', 'image', props.pageId, props.blockId], { fetcher: ([_, __, pageId, blockId]) => { - const page = props.workspace.getDoc(pageId); + const page = props.docCollection.getDoc(pageId); assertExists(page); const block = page.getBlockById(blockId) as ImageBlockModel; assertExists(block); - return props.workspace.blob.get(block?.sourceId as string); + return props.docCollection.blob.get(block?.sourceId as string); }, suspense: true, } @@ -508,7 +508,7 @@ export const ImagePreviewModal = ( return; } - const workspace = props.workspace; + const workspace = props.docCollection; const page = workspace.getDoc(props.pageId); assertExists(page); @@ -541,7 +541,7 @@ export const ImagePreviewModal = ( event.preventDefault(); event.stopPropagation(); }, - [blockId, setBlockId, props.workspace, props.pageId, isOpen, setIsOpen] + [blockId, setBlockId, props.docCollection, props.pageId, isOpen, setIsOpen] ); useEffect(() => { diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index 6fb824b7c8..719d6110f7 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -1,9 +1,9 @@ import './page-detail-editor.css'; -import { useBlockSuiteWorkspacePage } from '@affine/core/hooks/use-block-suite-workspace-page'; +import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspace-page'; import { assertExists, DisposableGroup } from '@blocksuite/global/utils'; import type { AffineEditorContainer } from '@blocksuite/presets'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import type { Doc as BlockSuiteDoc } from '@blocksuite/store'; import { Doc, @@ -34,7 +34,7 @@ export type OnLoadEditor = ( export interface PageDetailEditorProps { isPublic?: boolean; publishMode?: PageMode; - workspace: Workspace; + docCollection: DocCollection; pageId: string; onLoad?: OnLoadEditor; } @@ -78,7 +78,7 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({ const disposableGroup = new DisposableGroup(); disposableGroup.add( page.slots.blockUpdated.once(() => { - page.workspace.setDocMeta(page.id, { + page.collection.setDocMeta(page.id, { updatedDate: Date.now(), }); }) @@ -88,6 +88,7 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({ if (onLoad) { // Invoke onLoad once the editor has been mounted to the DOM. editor.updateComplete + .then(() => editor.host.updateComplete) .then(() => { disposableGroup.add(onLoad(page, editor)); }) @@ -120,8 +121,8 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({ }); export const PageDetailEditor = (props: PageDetailEditorProps) => { - const { workspace, pageId } = props; - const page = useBlockSuiteWorkspacePage(workspace, pageId); + const { docCollection, pageId } = props; + const page = useDocCollectionPage(docCollection, pageId); if (!page) { return null; } diff --git a/packages/frontend/core/src/components/page-list/__tests__/use-block-suite-page-preview.spec.ts b/packages/frontend/core/src/components/page-list/__tests__/use-block-suite-page-preview.spec.ts index 0b3dd8b34a..3187886a70 100644 --- a/packages/frontend/core/src/components/page-list/__tests__/use-block-suite-page-preview.spec.ts +++ b/packages/frontend/core/src/components/page-list/__tests__/use-block-suite-page-preview.spec.ts @@ -6,21 +6,21 @@ import 'fake-indexeddb/auto'; import { AffineSchemas } from '@blocksuite/blocks/schemas'; import { assertExists } from '@blocksuite/global/utils'; import type { Doc } from '@blocksuite/store'; -import { Schema, Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { DocCollection, Schema } from '@blocksuite/store'; import { renderHook } from '@testing-library/react'; import { useAtomValue } from 'jotai'; import { describe, expect, test, vi } from 'vitest'; import { beforeEach } from 'vitest'; import { useBlockSuitePagePreview } from '../use-block-suite-page-preview'; -let blockSuiteWorkspace: BlockSuiteWorkspace; +let docCollection: DocCollection; const schema = new Schema(); schema.register(AffineSchemas); beforeEach(async () => { vi.useFakeTimers({ toFake: ['requestIdleCallback'] }); - blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test', schema }); + docCollection = new DocCollection({ id: 'test', schema }); const initPage = async (page: Doc) => { page.load(); expect(page).not.toBeNull(); @@ -31,12 +31,12 @@ beforeEach(async () => { const frameId = page.addBlock('affine:note', {}, pageBlockId); page.addBlock('affine:paragraph', {}, frameId); }; - await initPage(blockSuiteWorkspace.createDoc({ id: 'page0' })); + await initPage(docCollection.createDoc({ id: 'page0' })); }); describe('useBlockSuitePagePreview', () => { test('basic', async () => { - const page = blockSuiteWorkspace.getDoc('page0') as Doc; + const page = docCollection.getDoc('page0') as Doc; const id = page.addBlock( 'affine:paragraph', { diff --git a/packages/frontend/core/src/components/page-list/collections/collection-list-header.css.ts b/packages/frontend/core/src/components/page-list/collections/collection-list-header.css.ts index 7ac21e2025..76467cb8de 100644 --- a/packages/frontend/core/src/components/page-list/collections/collection-list-header.css.ts +++ b/packages/frontend/core/src/components/page-list/collections/collection-list-header.css.ts @@ -16,6 +16,7 @@ export const collectionListHeaderTitle = style({ display: 'flex', alignItems: 'center', gap: 8, + userSelect: 'none', }); export const newCollectionButton = style({ padding: '6px 10px', diff --git a/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx b/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx index 2edea5dc8a..21d4c67ed3 100644 --- a/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/collections/collection-list-item.tsx @@ -144,7 +144,7 @@ export const CollectionListItem = (props: CollectionListItemProps) => { {props.operations ? ( diff --git a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx index fd68998420..ece5862afb 100644 --- a/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx +++ b/packages/frontend/core/src/components/page-list/collections/virtualized-collection-list.tsx @@ -31,7 +31,7 @@ const useCollectionOperationsRenderer = ({ config: AllPageListConfig; service: CollectionService; }) => { - const pageOperationsRenderer = useCallback( + const collectionOperationsRenderer = useCallback( (collection: Collection) => { return ( diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-header.css.ts b/packages/frontend/core/src/components/page-list/docs/page-list-header.css.ts index a094c4c455..b00cf43bc3 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-header.css.ts +++ b/packages/frontend/core/src/components/page-list/docs/page-list-header.css.ts @@ -17,6 +17,7 @@ export const docListHeaderTitle = style({ alignItems: 'center', gap: 8, height: '28px', + userSelect: 'none', }); export const titleIcon = style({ color: cssVar('iconColor'), @@ -50,6 +51,7 @@ export const tagSticky = style({ whiteSpace: 'nowrap', height: '22px', lineHeight: '1.67em', + cursor: 'pointer', }); export const tagIndicator = style({ width: '8px', @@ -62,3 +64,101 @@ export const tagLabel = style({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', }); +export const arrowDownSmallIcon = style({ + color: cssVar('iconColor'), + fontSize: '12px', +}); +export const searchIcon = style({ + color: cssVar('iconColor'), + fontSize: '20px', +}); + +export const tagsEditorRoot = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', + padding: '8px', +}); + +export const tagsMenu = style({ + padding: 0, + width: '296px', + overflow: 'hidden', +}); + +export const tagsEditorSelectedTags = style({ + display: 'flex', + gap: '8px', + flexWrap: 'nowrap', + padding: '6px 12px', + minHeight: 42, + alignItems: 'center', +}); + +export const searchInput = style({ + flexGrow: 1, + padding: '10px 0', + margin: '-10px 0', + border: 'none', + outline: 'none', + fontSize: cssVar('fontSm'), + fontFamily: 'inherit', + color: 'inherit', + backgroundColor: 'transparent', + '::placeholder': { + color: cssVar('placeholderColor'), + }, + overflow: 'hidden', +}); + +export const tagsEditorTagsSelector = style({ + display: 'flex', + flexDirection: 'column', + maxHeight: '400px', + overflow: 'auto', +}); + +export const tagSelectorTagsScrollContainer = style({ + overflowX: 'hidden', + position: 'relative', + maxHeight: '200px', + gap: '8px', +}); + +export const tagSelectorItem = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: '4px 16px', + height: '32px', + gap: 8, + fontSize: cssVar('fontSm'), + cursor: 'pointer', + borderRadius: '4px', + color: cssVar('textPrimaryColor'), + ':hover': { + backgroundColor: cssVar('hoverColor'), + }, + ':visited': { + color: cssVar('textPrimaryColor'), + }, + selectors: { + '&.disable:hover': { + backgroundColor: 'unset', + cursor: 'auto', + }, + }, +}); + +export const tagIcon = style({ + width: '8px', + height: '8px', + borderRadius: '50%', + flexShrink: 0, +}); + +export const tagSelectorItemText = style({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); diff --git a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx index 4babf7d683..363fdd8cb9 100644 --- a/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx +++ b/packages/frontend/core/src/components/page-list/docs/page-list-header.tsx @@ -1,12 +1,19 @@ -import { Button } from '@affine/component'; +import { Button, Divider, Menu, Scrollable } from '@affine/component'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; +import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace'; import type { Collection, Tag } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { ViewLayersIcon } from '@blocksuite/icons'; -import { useService } from '@toeverything/infra/di'; +import { + ArrowDownSmallIcon, + SearchIcon, + ViewLayersIcon, +} from '@blocksuite/icons'; +import { useLiveData, useService } from '@toeverything/infra'; +import clsx from 'clsx'; import { nanoid } from 'nanoid'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { CollectionService } from '../../../modules/collection'; import { createTagFilter } from '../filter/utils'; @@ -88,9 +95,12 @@ export const TagPageListHeader = ({ tag: Tag; workspaceId: string; }) => { + const legacyProperties = useService(WorkspaceLegacyProperties); + const options = useLiveData(legacyProperties.tagOptions$); const t = useAFFiNEI18N(); const { jumpToTags, jumpToCollection } = useNavigateHelper(); const collectionService = useService(CollectionService); + const [openMenu, setOpenMenu] = useState(false); const { open, node } = useEditCollectionName({ title: t['com.affine.editCollection.saveCollection'](), showTips: true, @@ -131,15 +141,31 @@ export const TagPageListHeader = ({ > {t['Tags']()} /
-
-
-
{tag.value}
-
+ } + > +
+
+
{tag.value}
+ +
+
+ + + + + +
+ ); +}; diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-header.css.ts b/packages/frontend/core/src/components/page-list/tags/tag-list-header.css.ts index 0d38513db0..76f8ec7c40 100644 --- a/packages/frontend/core/src/components/page-list/tags/tag-list-header.css.ts +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-header.css.ts @@ -16,6 +16,7 @@ export const tagListHeaderTitle = style({ display: 'flex', alignItems: 'center', gap: 8, + userSelect: 'none', }); export const newTagButton = style({ padding: '6px 10px', diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-header.tsx b/packages/frontend/core/src/components/page-list/tags/tag-list-header.tsx index b225853867..02662c8b0a 100644 --- a/packages/frontend/core/src/components/page-list/tags/tag-list-header.tsx +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-header.tsx @@ -1,12 +1,16 @@ +import { Button } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import * as styles from './tag-list-header.css'; -export const TagListHeader = () => { +export const TagListHeader = ({ onOpen }: { onOpen: () => void }) => { const t = useAFFiNEI18N(); return (
{t['Tags']()}
+
); }; diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts b/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts index 7af6206cbd..0e1d0b7807 100644 --- a/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-item.css.ts @@ -87,7 +87,7 @@ globalStyle(`${root} > :last-child`, { paddingRight: '8px', }); export const titleIconsWrapper = style({ - padding: '0 5px', + padding: '5px', display: 'flex', alignItems: 'center', gap: '10px', @@ -119,7 +119,7 @@ export const titleCellPreview = style({ overflow: 'hidden', color: cssVar('textSecondaryColor'), fontSize: cssVar('fontBase'), - flex: 1, + flexShrink: 0, whiteSpace: 'nowrap', textOverflow: 'ellipsis', alignSelf: 'stretch', @@ -162,6 +162,13 @@ export const operationsCell = style({ columnGap: '6px', flexShrink: 0, }); +export const tagIndicatorWrapper = style({ + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); export const tagIndicator = style({ width: '8px', height: '8px', diff --git a/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx b/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx index 1e2bc739fd..506ed92bba 100644 --- a/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx +++ b/packages/frontend/core/src/components/page-list/tags/tag-list-item.tsx @@ -14,9 +14,9 @@ const TagListTitleCell = ({ }: Pick) => { const t = useAFFiNEI18N(); return ( -
+
{title || t['Untitled']()} @@ -25,7 +25,7 @@ const TagListTitleCell = ({ data-testid="page-list-item-preview-text" className={styles.titleCellPreview} > - {`· ${pageCount} doc(s)`} + {` · ${t['com.affine.tags.count']({ count: pageCount || 0 })}`}
); @@ -33,12 +33,14 @@ const TagListTitleCell = ({ const ListIconCell = ({ color }: Pick) => { return ( -
+
+
+
); }; @@ -138,7 +140,7 @@ export const TagListItem = (props: TagListItemProps) => { {props.operations ? ( diff --git a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx index 71adb30e53..cee2a78e65 100644 --- a/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx +++ b/packages/frontend/core/src/components/page-list/tags/virtualized-tag-list.tsx @@ -1,4 +1,6 @@ +import { toast } from '@affine/component'; import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import type { Tag } from '@blocksuite/store'; import { useService } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra'; @@ -6,10 +8,12 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { ListFloatingToolbar } from '../components/list-floating-toolbar'; import { tagHeaderColsDef } from '../header-col-def'; +import { TagOperationCell } from '../operation-cell'; import { TagListItemRenderer } from '../page-group'; import { ListTableHeader } from '../page-header'; import type { ItemListHandle, ListItem, TagMeta } from '../types'; import { VirtualizedList } from '../virtualized-list'; +import { CreateOrEditTag } from './create-tag'; import { TagListHeader } from './tag-list-header'; export const VirtualizedTagList = ({ @@ -21,11 +25,20 @@ export const VirtualizedTagList = ({ tagMetas: TagMeta[]; onTagDelete: (tagIds: string[]) => void; }) => { + const t = useAFFiNEI18N(); const listRef = useRef(null); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); + const [showCreateTagInput, setShowCreateTagInput] = useState(false); const [selectedTagIds, setSelectedTagIds] = useState([]); const currentWorkspace = useService(Workspace); + const tagOperations = useCallback( + (tag: TagMeta) => { + return ; + }, + [onTagDelete] + ); + const filteredSelectedTagIds = useMemo(() => { const ids = tags.map(tag => tag.id); return selectedTagIds.filter(id => ids.includes(id)); @@ -35,13 +48,25 @@ export const VirtualizedTagList = ({ listRef.current?.toggleSelectable(); }, []); - const tagOperationRenderer = useCallback(() => { - return null; - }, []); + const tagOperationRenderer = useCallback( + (item: ListItem) => { + const tag = item as TagMeta; + return tagOperations(tag); + }, + [tagOperations] + ); const tagHeaderRenderer = useCallback(() => { - return ; - }, []); + return ( + <> + + + + ); + }, [showCreateTagInput]); const tagItemRenderer = useCallback((item: ListItem) => { return ; @@ -49,26 +74,31 @@ export const VirtualizedTagList = ({ const handleDelete = useCallback(() => { onTagDelete(selectedTagIds); + toast(t['com.affine.delete-tags.count']({ count: selectedTagIds.length })); hideFloatingToolbar(); return; - }, [hideFloatingToolbar, onTagDelete, selectedTagIds]); + }, [hideFloatingToolbar, onTagDelete, selectedTagIds, t]); + + const onOpenCreate = useCallback(() => { + setShowCreateTagInput(true); + }, [setShowCreateTagInput]); return ( <> } + heading={} selectedIds={filteredSelectedTagIds} onSelectedIdsChange={setSelectedTagIds} items={tagMetas} itemRenderer={tagItemRenderer} rowAsLink - blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} + docCollection={currentWorkspace.docCollection} operationsRenderer={tagOperationRenderer} headerRenderer={tagHeaderRenderer} /> diff --git a/packages/frontend/core/src/components/page-list/types.ts b/packages/frontend/core/src/components/page-list/types.ts index 0b871ef675..c215454e38 100644 --- a/packages/frontend/core/src/components/page-list/types.ts +++ b/packages/frontend/core/src/components/page-list/types.ts @@ -1,5 +1,5 @@ import type { Collection, Tag } from '@affine/env/filter'; -import type { DocMeta, Workspace } from '@blocksuite/store'; +import type { DocCollection, DocMeta } from '@blocksuite/store'; import type { PropsWithChildren, ReactNode } from 'react'; import type { To } from 'react-router-dom'; @@ -86,7 +86,7 @@ export type DateKey = 'createDate' | 'updatedDate'; export interface ListProps { // required data: items: T[]; - blockSuiteWorkspace: Workspace; + docCollection: DocCollection; className?: string; hideHeader?: boolean; // whether or not to hide the header. default is false (showing header) groupBy?: ItemGroupByType | false; @@ -104,7 +104,7 @@ export interface ListProps { export interface VirtualizedListProps extends ListProps { heading?: ReactNode; // the user provided heading part (non sticky, above the original header) - headerRenderer?: () => ReactNode; // the user provided header renderer + headerRenderer?: (item?: T) => ReactNode; // the user provided header renderer itemRenderer?: (item: T) => ReactNode; // the user provided item renderer atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0 atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not diff --git a/packages/frontend/core/src/components/page-list/use-block-suite-workspace-page.ts b/packages/frontend/core/src/components/page-list/use-block-suite-workspace-page.ts index 90fd2e31d9..a020eff1df 100644 --- a/packages/frontend/core/src/components/page-list/use-block-suite-workspace-page.ts +++ b/packages/frontend/core/src/components/page-list/use-block-suite-workspace-page.ts @@ -1,29 +1,29 @@ import { DebugLogger } from '@affine/debug'; import { DisposableGroup } from '@blocksuite/global/utils'; -import type { Doc, Workspace } from '@blocksuite/store'; +import type { Doc, DocCollection } from '@blocksuite/store'; import { useEffect, useState } from 'react'; const logger = new DebugLogger('useBlockSuiteWorkspacePage'); -export function useBlockSuiteWorkspacePage( - blockSuiteWorkspace: Workspace, +export function useDocCollectionPage( + docCollection: DocCollection, pageId: string | null ): Doc | null { const [page, setPage] = useState( - pageId ? blockSuiteWorkspace.getDoc(pageId) : null + pageId ? docCollection.getDoc(pageId) : null ); useEffect(() => { const group = new DisposableGroup(); group.add( - blockSuiteWorkspace.slots.docAdded.on(id => { + docCollection.slots.docAdded.on(id => { if (pageId === id) { - setPage(blockSuiteWorkspace.getDoc(id)); + setPage(docCollection.getDoc(id)); } }) ); group.add( - blockSuiteWorkspace.slots.docRemoved.on(id => { + docCollection.slots.docRemoved.on(id => { if (pageId === id) { setPage(null); } @@ -32,7 +32,7 @@ export function useBlockSuiteWorkspacePage( return () => { group.dispose(); }; - }, [blockSuiteWorkspace, pageId]); + }, [docCollection, pageId]); useEffect(() => { if (page && !page.loaded) { @@ -46,9 +46,9 @@ export function useBlockSuiteWorkspacePage( useEffect(() => { if (page?.id !== pageId) { - setPage(pageId ? blockSuiteWorkspace.getDoc(pageId) : null); + setPage(pageId ? docCollection.getDoc(pageId) : null); } - }, [blockSuiteWorkspace, page?.id, pageId]); + }, [docCollection, page?.id, pageId]); return page; } diff --git a/packages/frontend/core/src/components/page-list/use-tag-metas.ts b/packages/frontend/core/src/components/page-list/use-tag-metas.ts index 4d720973ba..2e6f9d5e05 100644 --- a/packages/frontend/core/src/components/page-list/use-tag-metas.ts +++ b/packages/frontend/core/src/components/page-list/use-tag-metas.ts @@ -1,14 +1,15 @@ -import type { DocMeta, Tag, Workspace } from '@blocksuite/store'; +import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace'; +import type { DocMeta } from '@blocksuite/store'; +import { useLiveData, useService } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; interface TagUsageCounts { [key: string]: number; } -export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) { - const tags = useMemo(() => { - return currentWorkspace.meta.properties.tags?.options || []; - }, [currentWorkspace]); +export function useTagMetas(pageMetas: DocMeta[]) { + const legacyProperties = useService(WorkspaceLegacyProperties); + const tags = useLiveData(legacyProperties.tagOptions$); const [tagMetas, tagUsageCounts] = useMemo(() => { const tagUsageCounts: TagUsageCounts = {}; @@ -42,47 +43,22 @@ export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) { const filterPageMetaByTag = useCallback( (tagId: string) => { return pageMetas.filter(page => { + if (!page.tags) { + return false; + } return page.tags.includes(tagId); }); }, [pageMetas] ); - const addNewTag = useCallback( - (tag: Tag) => { - const newTags = [...tags, tag]; - currentWorkspace.meta.setProperties({ - tags: { options: newTags }, - }); - }, - [currentWorkspace.meta, tags] - ); - - const updateTag = useCallback( - (tag: Tag) => { - const newTags = tags.map(t => { - if (t.id === tag.id) { - return tag; - } - return t; - }); - currentWorkspace.meta.setProperties({ - tags: { options: newTags }, - }); - }, - [currentWorkspace.meta, tags] - ); - const deleteTags = useCallback( (tagIds: string[]) => { - const newTags = tags.filter(tag => { - return !tagIds.includes(tag.id); - }); - currentWorkspace.meta.setProperties({ - tags: { options: newTags }, + tagIds.forEach(tagId => { + legacyProperties.removeTagOption(tagId); }); }, - [currentWorkspace.meta, tags] + [legacyProperties] ); return { @@ -90,8 +66,6 @@ export function useTagMetas(currentWorkspace: Workspace, pageMetas: DocMeta[]) { tagMetas, tagUsageCounts, filterPageMetaByTag, - addNewTag, - updateTag, deleteTags, }; } diff --git a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx index 50e6236c18..464a1f6094 100644 --- a/packages/frontend/core/src/components/page-list/view/collection-operations.tsx +++ b/packages/frontend/core/src/components/page-list/view/collection-operations.tsx @@ -4,9 +4,16 @@ import { MenuItem, type MenuItemProps, } from '@affine/component'; +import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; +import { Workbench } from '@affine/core/modules/workbench'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { DeleteIcon, EditIcon, FilterIcon } from '@blocksuite/icons'; +import { + DeleteIcon, + EditIcon, + FilterIcon, + SplitViewIcon, +} from '@blocksuite/icons'; import { useService } from '@toeverything/infra/di'; import { type PropsWithChildren, @@ -35,7 +42,9 @@ export const CollectionOperations = ({ config: AllPageListConfig; openRenameModal?: () => void; }>) => { + const { appSettings } = useAppSettingHelper(); const service = useService(CollectionService); + const workbench = useService(Workbench); const { open: openEditCollectionModal, node: editModal } = useEditCollection(config); const t = useAFFiNEI18N(); @@ -71,6 +80,10 @@ export const CollectionOperations = ({ }); }, [openEditCollectionModal, collection, service]); + const openCollectionSplitView = useCallback(() => { + workbench.openCollection(collection.id, { at: 'tail' }); + }, [collection.id, workbench]); + const actions = useMemo< Array< | { @@ -104,6 +117,19 @@ export const CollectionOperations = ({ name: t['com.affine.collection.menu.edit'](), click: showEdit, }, + ...(appSettings.enableMultiView + ? [ + { + icon: ( + + + + ), + name: t['com.affine.workbench.split-view.page-menu-open'](), + click: openCollectionSplitView, + }, + ] + : []), { element:
, }, @@ -120,7 +146,16 @@ export const CollectionOperations = ({ type: 'danger', }, ], - [t, showEditName, showEdit, service, info, collection.id] + [ + t, + showEditName, + showEdit, + appSettings.enableMultiView, + openCollectionSplitView, + service, + info, + collection.id, + ] ); return ( <> diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.tsx index 4840b7c5fe..ecde710eaa 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/edit-collection.tsx @@ -6,7 +6,7 @@ import { } from '@affine/component'; import type { Collection } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { DocMeta, Workspace } from '@blocksuite/store'; +import type { DocCollection, DocMeta } from '@blocksuite/store'; import type { DialogContentProps } from '@radix-ui/react-dialog'; import { type ReactNode, useCallback, useMemo, useState } from 'react'; @@ -199,7 +199,7 @@ export const EditCollection = ({ export type AllPageListConfig = { allPages: DocMeta[]; - workspace: Workspace; + docCollection: DocCollection; isEdgeless: (id: string) => boolean; /** * Return `undefined` if the page is not public diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx index 9eca9e40ac..5a328f7bba 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/pages-mode.tsx @@ -87,7 +87,9 @@ export const PagesMode = ({
@@ -114,7 +116,7 @@ export const PagesMode = ({ {showFilter ? (
@@ -125,7 +127,7 @@ export const PagesMode = ({ className={styles.pageList} items={searchedList} groupBy={false} - blockSuiteWorkspace={allPageListConfig.workspace} + docCollection={allPageListConfig.docCollection} selectable onSelectedIdsChange={ids => { updateCollection({ diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx index b60d937549..d81cf2fb35 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/rules-mode.tsx @@ -123,7 +123,7 @@ export const RulesMode = ({ }} > updateCollection({ ...collection, filterList }), @@ -265,7 +265,7 @@ export const RulesMode = ({ className={styles.resultPages} items={rulesPages} groupBy={false} - blockSuiteWorkspace={allPageListConfig.workspace} + docCollection={allPageListConfig.docCollection} isPreferredEdgeless={allPageListConfig.isEdgeless} operationsRenderer={operationsRenderer} > @@ -285,7 +285,7 @@ export const RulesMode = ({ className={styles.resultPages} items={allowListPages} groupBy={false} - blockSuiteWorkspace={allPageListConfig.workspace} + docCollection={allPageListConfig.docCollection} isPreferredEdgeless={allPageListConfig.isEdgeless} operationsRenderer={operationsRenderer} > diff --git a/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx b/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx index 3b189839a1..457c4c9049 100644 --- a/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx +++ b/packages/frontend/core/src/components/page-list/view/edit-collection/select-page.tsx @@ -76,7 +76,9 @@ export const SelectPage = ({ @@ -103,7 +105,7 @@ export const SelectPage = ({ {showFilter ? (
@@ -113,7 +115,7 @@ export const SelectPage = ({ extends BaseVirtuosoItem { type: 'sticky-header'; + data?: T; } interface VirtuosoItemItem extends BaseVirtuosoItem { @@ -61,7 +62,7 @@ interface VirtuosoPageItemSpacer extends BaseVirtuosoItem { } type VirtuosoItem = - | VirtuosoItemStickyHeader + | VirtuosoItemStickyHeader | VirtuosoItemItem | VirtuosoItemGroupHeader | VirtuosoPageItemSpacer; @@ -187,7 +188,7 @@ const ListInner = ({ (_index: number, data: VirtuosoItem) => { switch (data.type) { case 'sticky-header': - return props.headerRenderer?.(); + return props.headerRenderer?.(data.data); case 'group-header': return ; case 'item': diff --git a/packages/frontend/core/src/components/pure/cmdk/data-hooks.tsx b/packages/frontend/core/src/components/pure/cmdk/data-hooks.tsx index d9dd666948..07e0a31601 100644 --- a/packages/frontend/core/src/components/pure/cmdk/data-hooks.tsx +++ b/packages/frontend/core/src/components/pure/cmdk/data-hooks.tsx @@ -2,7 +2,7 @@ import { useBlockSuiteDocMeta, useDocMetaHelper, } from '@affine/core/hooks/use-block-suite-page-meta'; -import { useGetBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; +import { useGetDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; import { useJournalHelper } from '@affine/core/hooks/use-journal'; import { CollectionService } from '@affine/core/modules/collection'; import { WorkspaceSubPath } from '@affine/core/shared'; @@ -79,7 +79,7 @@ function getAllCommand(context: CommandContext) { const useWorkspacePages = () => { const workspace = useService(Workspace); - const pages = useBlockSuiteDocMeta(workspace.blockSuiteWorkspace); + const pages = useBlockSuiteDocMeta(workspace.docCollection); return pages; }; @@ -100,7 +100,7 @@ export const pageToCommand = ( category: CommandCategory, page: DocMeta, navigationHelper: ReturnType, - getPageTitle: ReturnType, + getPageTitle: ReturnType, isPageJournal: (pageId: string) => boolean, t: ReturnType, workspace: Workspace, @@ -151,16 +151,14 @@ export const usePageCommands = () => { const recentPages = useRecentPages(); const pages = useWorkspacePages(); const workspace = useService(Workspace); - const pageHelper = usePageHelper(workspace.blockSuiteWorkspace); - const pageMetaHelper = useDocMetaHelper(workspace.blockSuiteWorkspace); + const pageHelper = usePageHelper(workspace.docCollection); + const pageMetaHelper = useDocMetaHelper(workspace.docCollection); const query = useAtomValue(cmdkQueryAtom); const navigationHelper = useNavigateHelper(); - const journalHelper = useJournalHelper(workspace.blockSuiteWorkspace); + const journalHelper = useJournalHelper(workspace.docCollection); const t = useAFFiNEI18N(); - const getPageTitle = useGetBlockSuiteWorkspacePageTitle( - workspace.blockSuiteWorkspace - ); - const { isPageJournal } = useJournalHelper(workspace.blockSuiteWorkspace); + const getPageTitle = useGetDocCollectionPageTitle(workspace.docCollection); + const { isPageJournal } = useJournalHelper(workspace.docCollection); const [searchTime, setSearchTime] = useState(0); @@ -197,7 +195,7 @@ export const usePageCommands = () => { } else { // queried pages that has matched contents // TODO: we shall have a debounce for global search here - const searchResults = workspace.blockSuiteWorkspace.search({ + const searchResults = workspace.docCollection.search({ query, }) as unknown as Map; const resultValues = Array.from(searchResults.values()); @@ -242,7 +240,7 @@ export const usePageCommands = () => { if (!appendRes) return; const { page, blockId } = appendRes; navigationHelper.jumpToPageBlock( - page.workspace.id, + page.collection.id, page.id, blockId ); diff --git a/packages/frontend/core/src/components/pure/footer/index.tsx b/packages/frontend/core/src/components/pure/footer/index.tsx index a8a0af8d0f..68ef7dcc72 100644 --- a/packages/frontend/core/src/components/pure/footer/index.tsx +++ b/packages/frontend/core/src/components/pure/footer/index.tsx @@ -25,7 +25,7 @@ const SignInButton = () => { { - signInCloud().catch(console.error); + signInCloud('email').catch(console.error); }, [])} >
diff --git a/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx b/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx index 815d780086..18c6c3cf6c 100644 --- a/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx +++ b/packages/frontend/core/src/components/pure/trash-page-footer/index.tsx @@ -22,15 +22,15 @@ export const TrashPageFooter = ({ pageId }: { pageId: string }) => { useService(CurrentWorkspaceService).currentWorkspace ); assertExists(workspace); - const blockSuiteWorkspace = workspace.blockSuiteWorkspace; - const pageMeta = useBlockSuiteDocMeta(blockSuiteWorkspace).find( + const docCollection = workspace.docCollection; + const pageMeta = useBlockSuiteDocMeta(docCollection).find( meta => meta.id === pageId ); assertExists(pageMeta); const t = useAFFiNEI18N(); const { appSettings } = useAppSettingHelper(); const { jumpToSubPath } = useNavigateHelper(); - const { restoreFromTrash } = useBlockSuiteMetaHelper(blockSuiteWorkspace); + const { restoreFromTrash } = useBlockSuiteMetaHelper(docCollection); const [open, setOpen] = useState(false); const hintText = t['com.affine.cmdk.affine.editor.trash-footer-hint'](); @@ -45,9 +45,9 @@ export const TrashPageFooter = ({ pageId }: { pageId: string }) => { const onConfirmDelete = useCallback(() => { jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); - blockSuiteWorkspace.removeDoc(pageId); + docCollection.removeDoc(pageId); toast(t['com.affine.toastMessage.permanentlyDeleted']()); - }, [blockSuiteWorkspace, jumpToSubPath, pageId, workspace.id, t]); + }, [docCollection, jumpToSubPath, pageId, workspace.id, t]); const onDelete = useCallback(() => { setOpen(true); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx index bb9fefc7fb..3fd422a882 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/collections-list.tsx @@ -10,7 +10,7 @@ import { CollectionService } from '@affine/core/modules/collection'; import type { Collection, DeleteCollectionInfo } from '@affine/env/filter'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons'; -import type { DocMeta, Workspace } from '@blocksuite/store'; +import type { DocCollection, DocMeta } from '@blocksuite/store'; import { useDroppable } from '@dnd-kit/core'; import * as Collapsible from '@radix-ui/react-collapsible'; import { useService } from '@toeverything/infra'; @@ -30,12 +30,12 @@ import * as styles from './styles.css'; const CollectionRenderer = ({ collection, pages, - workspace, + docCollection, info, }: { collection: Collection; pages: DocMeta[]; - workspace: Workspace; + docCollection: DocCollection; info: DeleteCollectionInfo; }) => { const [collapsed, setCollapsed] = useState(true); @@ -159,7 +159,7 @@ const CollectionRenderer = ({ allPageMeta={allPagesMeta} page={page} key={page.id} - workspace={workspace} + docCollection={docCollection} /> ); })} @@ -169,7 +169,7 @@ const CollectionRenderer = ({ ); }; export const CollectionsList = ({ - workspace, + docCollection: workspace, info, onCreate, }: CollectionsListProps) => { @@ -205,7 +205,7 @@ export const CollectionsList = ({ key={view.id} collection={view} pages={metas} - workspace={workspace} + docCollection={workspace} /> ); })} diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx index bd0414d1fe..019c255097 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/collections/page.tsx @@ -1,7 +1,7 @@ import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; -import type { DocMeta, Workspace } from '@blocksuite/store'; +import type { DocCollection, DocMeta } from '@blocksuite/store'; import { useDraggable } from '@dnd-kit/core'; import * as Collapsible from '@radix-ui/react-collapsible'; import { PageRecordList, useLiveData, useService } from '@toeverything/infra'; @@ -18,7 +18,7 @@ import * as styles from './styles.css'; export const Page = ({ page, - workspace, + docCollection, allPageMeta, inAllowList, removeFromAllowList, @@ -26,7 +26,7 @@ export const Page = ({ page: DocMeta; inAllowList: boolean; removeFromAllowList: (id: string) => void; - workspace: Workspace; + docCollection: DocCollection; allPageMeta: Record; }) => { const [collapsed, setCollapsed] = React.useState(true); @@ -46,10 +46,10 @@ export const Page = ({ const { jumpToPage } = useNavigateHelper(); const clickPage = useCallback(() => { - jumpToPage(workspace.id, page.id); - }, [jumpToPage, page.id, workspace.id]); + jumpToPage(docCollection.id, page.id); + }, [jumpToPage, page.id, docCollection.id]); - const references = useBlockSuitePageReferences(workspace, pageId); + const references = useBlockSuitePageReferences(docCollection, pageId); const referencesToRender = references.filter( id => allPageMeta[id] && !allPageMeta[id]?.trash ); @@ -85,7 +85,7 @@ export const Page = ({ onCollapsedChange={setCollapsed} postfix={ void; onRemoveFromFavourites?: () => void; onDelete: () => void; + onOpenInSplitView: () => void; }; export const OperationItems = ({ @@ -35,7 +38,9 @@ export const OperationItems = ({ onAddLinkedPage, onRemoveFromFavourites, onDelete, + onOpenInSplitView, }: OperationItemsProps) => { + const { appSettings } = useAppSettingHelper(); const t = useAFFiNEI18N(); const actions = useMemo< Array< @@ -81,9 +86,6 @@ export const OperationItems = ({ name: t['Remove from favorites'](), click: onRemoveFromFavourites, }, - { - element: , - }, ] : []), ...(inAllowList && onRemoveFromAllowList @@ -97,18 +99,27 @@ export const OperationItems = ({ name: t['Remove special filter'](), click: onRemoveFromAllowList, }, - { - element: , - }, ] : []), - ...(isReferencePage + + ...(appSettings.enableMultiView ? [ + // open split view { - element: , + icon: ( + + + + ), + name: t['com.affine.workbench.split-view.page-menu-open'](), + click: onOpenInSplitView, }, ] : []), + + { + element: , + }, { icon: ( @@ -121,14 +132,16 @@ export const OperationItems = ({ }, ], [ + t, onRename, onAddLinkedPage, inFavorites, onRemoveFromFavourites, isReferencePage, - t, inAllowList, onRemoveFromAllowList, + appSettings.enableMultiView, + onOpenInSplitView, onDelete, ] ); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx index 6cc43847fe..fc81fb7988 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx @@ -1,9 +1,11 @@ import { toast } from '@affine/component'; import { IconButton } from '@affine/component/ui/button'; import { Menu } from '@affine/component/ui/menu'; +import { Workbench } from '@affine/core/modules/workbench'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { MoreHorizontalIcon } from '@blocksuite/icons'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; +import { useService } from '@toeverything/infra/di'; import { useCallback } from 'react'; import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper'; @@ -13,7 +15,7 @@ import { OperationItems } from './operation-item'; export type OperationMenuButtonProps = { pageId: string; - workspace: Workspace; + docCollection: DocCollection; pageTitle: string; setRenameModalOpen: () => void; inFavorites?: boolean; @@ -24,7 +26,7 @@ export type OperationMenuButtonProps = { export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { const { - workspace, + docCollection, pageId, pageTitle, setRenameModalOpen, @@ -34,9 +36,10 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { isReferencePage, } = props; const t = useAFFiNEI18N(); - const { createLinkedPage } = usePageHelper(workspace); - const { setTrashModal } = useTrashModalHelper(workspace); - const { removeFromFavorite } = useBlockSuiteMetaHelper(workspace); + const { createLinkedPage } = usePageHelper(docCollection); + const { setTrashModal } = useTrashModalHelper(docCollection); + const { removeFromFavorite } = useBlockSuiteMetaHelper(docCollection); + const workbench = useService(Workbench); const handleRename = useCallback(() => { setRenameModalOpen?.(); @@ -64,6 +67,10 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { removeFromAllowList?.(pageId); }, [pageId, removeFromAllowList]); + const handleOpenInSplitView = useCallback(() => { + workbench.openPage(pageId, { at: 'tail' }); + }, [pageId, workbench]); + return ( { onRemoveFromAllowList={handleRemoveFromAllowList} onRemoveFromFavourites={handleRemoveFromFavourites} onRename={handleRename} + onOpenInSplitView={handleOpenInSplitView} inAllowList={inAllowList} inFavorites={inFavorites} isReferencePage={isReferencePage} diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx index d4999b92f6..8d929af7ad 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/postfix-item.tsx @@ -2,7 +2,7 @@ import { toast } from '@affine/component'; import { RenameModal } from '@affine/component/rename-modal'; import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { useCallback, useState } from 'react'; import { AddFavouriteButton } from '../favorite/add-favourite-button'; @@ -10,7 +10,7 @@ import * as styles from '../favorite/styles.css'; import { OperationMenuButton } from './operation-menu-button'; type PostfixItemProps = { - workspace: Workspace; + docCollection: DocCollection; pageId: string; pageTitle: string; inFavorites?: boolean; @@ -20,10 +20,10 @@ type PostfixItemProps = { }; export const PostfixItem = ({ ...props }: PostfixItemProps) => { - const { workspace, pageId, pageTitle } = props; + const { docCollection, pageId, pageTitle } = props; const t = useAFFiNEI18N(); const [open, setOpen] = useState(false); - const { setDocTitle } = useDocMetaHelper(workspace); + const { setDocTitle } = useDocMetaHelper(docCollection); const handleRename = useCallback( (newName: string) => { diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx index d6f7a7513f..2a187e67f4 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/reference-page.tsx @@ -1,7 +1,7 @@ import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; -import { type DocMeta, type Workspace } from '@blocksuite/store'; +import { type DocCollection, type DocMeta } from '@blocksuite/store'; import * as Collapsible from '@radix-ui/react-collapsible'; import { PageRecordList, useLiveData, useService } from '@toeverything/infra'; import { useMemo, useState } from 'react'; @@ -11,14 +11,14 @@ import { MenuLinkItem } from '../../../app-sidebar'; import * as styles from '../favorite/styles.css'; import { PostfixItem } from './postfix-item'; export interface ReferencePageProps { - workspace: Workspace; + docCollection: DocCollection; pageId: string; metaMapping: Record; parentIds: Set; } export const ReferencePage = ({ - workspace, + docCollection, pageId, metaMapping, parentIds, @@ -33,7 +33,7 @@ export const ReferencePage = ({ return pageMode === 'edgeless' ? : ; }, [pageMode]); - const references = useBlockSuitePageReferences(workspace, pageId); + const references = useBlockSuitePageReferences(docCollection, pageId); const referencesToShow = useMemo(() => { return [ ...new Set( @@ -59,13 +59,13 @@ export const ReferencePage = ({ data-type="reference-page" data-testid={`reference-page-${pageId}`} active={active} - to={`/workspace/${workspace.id}/${pageId}`} + to={`/workspace/${docCollection.id}/${pageId}`} icon={icon} collapsed={collapsible ? collapsed : undefined} onCollapsedChange={setCollapsed} postfix={ { - const { createPage, createLinkedPage } = usePageHelper(workspace); - const { setDocMeta } = useDocMetaHelper(workspace); + const { createPage, createLinkedPage } = usePageHelper(docCollection); + const { setDocMeta } = useDocMetaHelper(docCollection); const handleAddFavorite = useAsyncCallback( async e => { if (pageId) { diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx index 189cc6092f..b2ed986642 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favorite-list.tsx @@ -11,7 +11,9 @@ import * as styles from './styles.css'; const emptyPageIdSet = new Set(); -export const FavoriteList = ({ workspace }: FavoriteListProps) => { +export const FavoriteList = ({ + docCollection: workspace, +}: FavoriteListProps) => { const metas = useBlockSuiteDocMeta(workspace); const dropItemId = getDropItemId('favorites'); @@ -51,7 +53,7 @@ export const FavoriteList = ({ workspace }: FavoriteListProps) => { pageId={pageMeta.id} // memo? parentIds={emptyPageIdSet} - workspace={workspace} + docCollection={workspace} /> ); })} diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-page.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-page.tsx index 0c653005a0..e1894bbcaa 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-page.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/favorite/favourite-page.tsx @@ -20,7 +20,7 @@ import { import * as styles from './styles.css'; export const FavouritePage = ({ - workspace, + docCollection: workspace, pageId, metaMapping, parentIds, @@ -86,7 +86,7 @@ export const FavouritePage = ({ {...listeners} postfix={ void; }; diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.css.ts index 26f78c7fe3..86650bbe09 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.css.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/index.css.ts @@ -49,3 +49,10 @@ export const signInTextSecondary = style({ export const menuItem = style({ borderRadius: '8px', }); +export const loadingWrapper = style({ + height: 42, + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: '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 289dc641a8..303db9826c 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 @@ -1,5 +1,7 @@ +import { Loading } from '@affine/component'; import { Divider } from '@affine/component/ui/divider'; import { MenuItem } from '@affine/component/ui/menu'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { Unreachable } from '@affine/env/constant'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { Logo1Icon } from '@blocksuite/icons'; @@ -7,9 +9,7 @@ import { WorkspaceManager } from '@toeverything/infra'; import { useService } from '@toeverything/infra/di'; import { useLiveData } from '@toeverything/infra/livedata'; import { useSetAtom } from 'jotai'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; -import { useCallback, useEffect, useMemo } from 'react'; +import { Suspense, useCallback, useEffect } from 'react'; import { authAtom, @@ -63,14 +63,24 @@ const SignInItem = () => { ); }; -export const UserWithWorkspaceList = ({ - onEventEnd, -}: { - onEventEnd?: () => void; -}) => { - const { data: session, status } = useSession(); +const UserWithWorkspaceListLoading = () => { + return ( +
+ +
+ ); +}; - const isAuthenticated = useMemo(() => status === 'authenticated', [status]); +interface UserWithWorkspaceListProps { + onEventEnd?: () => void; +} + +const UserWithWorkspaceListInner = ({ + onEventEnd, +}: UserWithWorkspaceListProps) => { + const { user, status } = useSession(); + + const isAuthenticated = status === 'authenticated'; const setOpenCreateWorkspaceModal = useSetAtom(openCreateWorkspaceModalAtom); const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom); @@ -124,7 +134,7 @@ export const UserWithWorkspaceList = ({
{isAuthenticated ? ( ) : ( @@ -140,3 +150,11 @@ export const UserWithWorkspaceList = ({
); }; + +export const UserWithWorkspaceList = (props: UserWithWorkspaceListProps) => { + return ( + }> + + + ); +}; 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 88b721533b..23b3a90f12 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 @@ -1,6 +1,7 @@ import { ScrollableContainer } from '@affine/component'; import { Divider } from '@affine/component/ui/divider'; import { WorkspaceList } from '@affine/component/workspace-list'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useWorkspaceAvatar, useWorkspaceName, @@ -12,8 +13,6 @@ import { WorkspaceManager, type WorkspaceMetadata } from '@toeverything/infra'; import { useService } from '@toeverything/infra/di'; import { useLiveData } from '@toeverything/infra/livedata'; import { useSetAtom } from 'jotai'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; import { useCallback, useMemo } from 'react'; import { @@ -119,10 +118,9 @@ export const AFFiNEWorkspaceList = ({ const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); - // TODO: AFFiNE Cloud support const { status } = useSession(); - const isAuthenticated = useMemo(() => status === 'authenticated', [status]); + const isAuthenticated = status === 'authenticated'; const cloudWorkspaces = useMemo( () => diff --git a/packages/frontend/core/src/components/root-app-sidebar/import-page.tsx b/packages/frontend/core/src/components/root-app-sidebar/import-page.tsx index 3a72594bc6..a0c8639c18 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/import-page.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/import-page.tsx @@ -1,17 +1,13 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ImportIcon } from '@blocksuite/icons'; -import type { BlockSuiteWorkspace } from '../../shared'; +import type { DocCollection } from '../../shared'; import { MenuItem } from '../app-sidebar'; import { usePageHelper } from '../blocksuite/block-suite-page-list/utils'; -const ImportPage = ({ - blocksuiteWorkspace, -}: { - blocksuiteWorkspace: BlockSuiteWorkspace; -}) => { +const ImportPage = ({ docCollection }: { docCollection: DocCollection }) => { const t = useAFFiNEI18N(); - const { importFile } = usePageHelper(blocksuiteWorkspace); + const { importFile } = usePageHelper(docCollection); return ( } onClick={importFile}> {t['Import']()} 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 67197e60ee..d97a5070af 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx @@ -100,7 +100,7 @@ export const RootAppSidebar = ({ }: RootAppSidebarProps): ReactElement => { const currentWorkspaceId = currentWorkspace.id; const { appSettings } = useAppSettingHelper(); - const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; + const docCollection = currentWorkspace.docCollection; const t = useAFFiNEI18N(); const [openUserWorkspaceList, setOpenUserWorkspaceList] = useAtom( openWorkspaceListModalAtom @@ -114,7 +114,7 @@ export const RootAppSidebar = ({ }, [createPage, openPage]); const { trashModal, setTrashModal, handleOnConfirm } = - useTrashModalHelper(blockSuiteWorkspace); + useTrashModalHelper(docCollection); const deletePageTitles = trashModal.pageTitles; const trashConfirmOpen = trashModal.open; const onTrashConfirmOpenChange = useCallback( @@ -164,12 +164,12 @@ export const RootAppSidebar = ({ .then(name => { const id = nanoid(); collection.addCollection(createEmptyCollection(id, { name })); - navigateHelper.jumpToCollection(blockSuiteWorkspace.id, id); + navigateHelper.jumpToCollection(docCollection.id, id); }) .catch(err => { console.error(err); }); - }, [blockSuiteWorkspace.id, collection, navigateHelper, open]); + }, [docCollection.id, collection, navigateHelper, open]); const allPageActive = currentPath === '/all'; @@ -178,11 +178,7 @@ export const RootAppSidebar = ({ return ( {runtimeConfig.enableNewSettingModal ? ( - + - + @@ -272,7 +268,7 @@ export const RootAppSidebar = ({ {t['com.affine.workspaceSubPath.trash']()} - + {environment.isDesktop ? : } diff --git a/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx b/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx index f745c57a66..059d13fb93 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx +++ b/packages/frontend/core/src/components/root-app-sidebar/journal-button.tsx @@ -2,7 +2,7 @@ import { useJournalInfoHelper, useJournalRouteHelper, } from '@affine/core/hooks/use-journal'; -import type { BlockSuiteWorkspace } from '@affine/core/shared'; +import type { DocCollection } from '@affine/core/shared'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { TodayIcon, TomorrowIcon, YesterdayIcon } from '@blocksuite/icons'; import { Doc, useServiceOptional } from '@toeverything/infra'; @@ -11,17 +11,17 @@ import { useParams } from 'react-router-dom'; import { MenuItem } from '../app-sidebar'; interface AppSidebarJournalButtonProps { - workspace: BlockSuiteWorkspace; + docCollection: DocCollection; } export const AppSidebarJournalButton = ({ - workspace, + docCollection, }: AppSidebarJournalButtonProps) => { const t = useAFFiNEI18N(); const currentPage = useServiceOptional(Doc); - const { openToday } = useJournalRouteHelper(workspace); + const { openToday } = useJournalRouteHelper(docCollection); const { journalDate, isJournal } = useJournalInfoHelper( - workspace, + docCollection, currentPage?.id ); const params = useParams(); diff --git a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-helper.spec.tsx b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-helper.spec.tsx index 724297eccd..44e906aba7 100644 --- a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-helper.spec.tsx +++ b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-helper.spec.tsx @@ -11,14 +11,14 @@ import type { PropsWithChildren } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { useBlockSuiteDocMeta } from '../use-block-suite-page-meta'; -import { useBlockSuiteWorkspaceHelper } from '../use-block-suite-workspace-helper'; +import { useDocCollectionHelper } from '../use-block-suite-workspace-helper'; const configureTestingWorkspace = async () => { const { workspace } = await configureTestingEnvironment(); - const blockSuiteWorkspace = workspace.blockSuiteWorkspace; + const docCollection = workspace.docCollection; - initEmptyPage(blockSuiteWorkspace.createDoc({ id: 'page1' })); - initEmptyPage(blockSuiteWorkspace.createDoc({ id: 'page2' })); + initEmptyPage(docCollection.createDoc({ id: 'page1' })); + initEmptyPage(docCollection.createDoc({ id: 'page2' })); return workspace; }; @@ -36,31 +36,25 @@ const getWrapper = (workspace: Workspace) => ); }; -describe('useBlockSuiteWorkspaceHelper', () => { +describe('useDocCollectionHelper', () => { test('should create page', async () => { const workspace = await configureTestingWorkspace(); - const blockSuiteWorkspace = workspace.blockSuiteWorkspace; + const docCollection = workspace.docCollection; const Wrapper = getWrapper(workspace); - expect(blockSuiteWorkspace.meta.docMetas.length).toBe(3); - const helperHook = renderHook( - () => useBlockSuiteWorkspaceHelper(blockSuiteWorkspace), - { - wrapper: Wrapper, - } - ); - const pageMetaHook = renderHook( - () => useBlockSuiteDocMeta(blockSuiteWorkspace), - { - wrapper: Wrapper, - } - ); + expect(docCollection.meta.docMetas.length).toBe(3); + const helperHook = renderHook(() => useDocCollectionHelper(docCollection), { + wrapper: Wrapper, + }); + const pageMetaHook = renderHook(() => useBlockSuiteDocMeta(docCollection), { + wrapper: Wrapper, + }); await new Promise(resolve => setTimeout(resolve)); expect(pageMetaHook.result.current.length).toBe(3); - expect(blockSuiteWorkspace.meta.docMetas.length).toBe(3); + expect(docCollection.meta.docMetas.length).toBe(3); const page = helperHook.result.current.createDoc('page4'); expect(page.id).toBe('page4'); - expect(blockSuiteWorkspace.meta.docMetas.length).toBe(4); + expect(docCollection.meta.docMetas.length).toBe(4); pageMetaHook.rerender(); expect(pageMetaHook.result.current.length).toBe(4); }); diff --git a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx index d638dc6857..60546c1fe2 100644 --- a/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx +++ b/packages/frontend/core/src/hooks/__tests__/use-block-suite-workspace-page-title.spec.tsx @@ -13,16 +13,13 @@ import { describe, expect, test, vi } from 'vitest'; import { beforeEach } from 'vitest'; import { configureTestingEnvironment } from '../../testing'; -import { useBlockSuiteWorkspacePageTitle } from '../use-block-suite-workspace-page-title'; +import { useDocCollectionPageTitle } from '../use-block-suite-workspace-page-title'; const store = createStore(); const Component = () => { const workspace = useService(Workspace); - const title = useBlockSuiteWorkspacePageTitle( - workspace.blockSuiteWorkspace, - 'page0' - ); + const title = useDocCollectionPageTitle(workspace.docCollection, 'page0'); return
title: {title}
; }; @@ -30,7 +27,7 @@ beforeEach(async () => { vi.useFakeTimers({ toFake: ['requestIdleCallback'] }); }); -describe('useBlockSuiteWorkspacePageTitle', () => { +describe('useDocCollectionPageTitle', () => { test('basic', async () => { const { workspace, page } = await configureTestingEnvironment(); const { findByText, rerender } = render( @@ -43,7 +40,7 @@ describe('useBlockSuiteWorkspacePageTitle', () => { ); expect(await findByText('title: Untitled')).toBeDefined(); - workspace.blockSuiteWorkspace.setDocMeta(page.id, { title: '1' }); + workspace.docCollection.setDocMeta(page.id, { title: '1' }); rerender( diff --git a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx index 73818ed627..6178a6c232 100644 --- a/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx +++ b/packages/frontend/core/src/hooks/affine/use-all-page-list-config.tsx @@ -17,7 +17,7 @@ import { usePublicPages } from './use-is-shared-page'; export const useAllPageListConfig = () => { const currentWorkspace = useService(Workspace); const { getPublicMode } = usePublicPages(currentWorkspace); - const workspace = currentWorkspace.blockSuiteWorkspace; + const workspace = currentWorkspace.docCollection; const pageMetas = useBlockSuiteDocMeta(workspace); const { isPreferredEdgeless } = usePageHelper(workspace); const pageMap = useMemo( @@ -25,7 +25,7 @@ export const useAllPageListConfig = () => { [pageMetas] ); const { toggleFavorite } = useBlockSuiteMetaHelper( - currentWorkspace.blockSuiteWorkspace + currentWorkspace.docCollection ); const t = useAFFiNEI18N(); const onToggleFavoritePage = useCallback( @@ -45,7 +45,7 @@ export const useAllPageListConfig = () => { allPages: pageMetas, isEdgeless: isPreferredEdgeless, getPublicMode, - workspace: currentWorkspace.blockSuiteWorkspace, + docCollection: currentWorkspace.docCollection, getPage: id => pageMap[id], favoriteRender: page => { return ( @@ -61,7 +61,7 @@ export const useAllPageListConfig = () => { pageMetas, isPreferredEdgeless, getPublicMode, - currentWorkspace.blockSuiteWorkspace, + currentWorkspace.docCollection, pageMap, onToggleFavoritePage, ]); diff --git a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts index 23cd9fd84b..d38d66e6e7 100644 --- a/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts +++ b/packages/frontend/core/src/hooks/affine/use-block-suite-meta-helper.ts @@ -1,22 +1,20 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { useBlockSuiteWorkspaceHelper } from '@affine/core/hooks/use-block-suite-workspace-helper'; +import { useDocCollectionHelper } from '@affine/core/hooks/use-block-suite-workspace-helper'; import { CollectionService } from '@affine/core/modules/collection'; import { PageRecordList, useService } from '@toeverything/infra'; import { useCallback } from 'react'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; -import type { BlockSuiteWorkspace } from '../../shared'; +import type { DocCollection } from '../../shared'; import { useNavigateHelper } from '../use-navigate-helper'; import { useReferenceLinkHelper } from './use-reference-link-helper'; -export function useBlockSuiteMetaHelper( - blockSuiteWorkspace: BlockSuiteWorkspace -) { +export function useBlockSuiteMetaHelper(docCollection: DocCollection) { const { setDocMeta, getDocMeta, setDocReadonly, setDocTitle } = - useDocMetaHelper(blockSuiteWorkspace); - const { addReferenceLink } = useReferenceLinkHelper(blockSuiteWorkspace); - const { createDoc } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace); + useDocMetaHelper(docCollection); + const { addReferenceLink } = useReferenceLinkHelper(docCollection); + const { createDoc } = useDocCollectionHelper(docCollection); const { openPage } = useNavigateHelper(); const collectionService = useService(CollectionService); const pageRecordList = useService(PageRecordList); @@ -82,9 +80,9 @@ export function useBlockSuiteMetaHelper( const permanentlyDeletePage = useCallback( (pageId: string) => { - blockSuiteWorkspace.removeDoc(pageId); + docCollection.removeDoc(pageId); }, - [blockSuiteWorkspace] + [docCollection] ); /** @@ -116,7 +114,7 @@ export function useBlockSuiteMetaHelper( const currentPageMode = pageRecordList.record(pageId).value?.mode.value; const currentPageMeta = getDocMeta(pageId); const newPage = createDoc(); - const currentPage = blockSuiteWorkspace.getDoc(pageId); + const currentPage = docCollection.getDoc(pageId); newPage.load(); if (!currentPageMeta || !currentPage) { @@ -142,10 +140,10 @@ export function useBlockSuiteMetaHelper( .record(newPage.id) .value?.setMode(currentPageMode || 'page'); setDocTitle(newPage.id, newPageTitle); - openPageAfterDuplication && openPage(blockSuiteWorkspace.id, newPage.id); + openPageAfterDuplication && openPage(docCollection.id, newPage.id); }, [ - blockSuiteWorkspace, + docCollection, createDoc, getDocMeta, openPage, diff --git a/packages/frontend/core/src/hooks/affine/use-current-login-status.ts b/packages/frontend/core/src/hooks/affine/use-current-login-status.ts index 4bd5ca2e0e..14ff11afa7 100644 --- a/packages/frontend/core/src/hooks/affine/use-current-login-status.ts +++ b/packages/frontend/core/src/hooks/affine/use-current-login-status.ts @@ -1,10 +1,6 @@ -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; +import { useSession } from './use-current-user'; -export function useCurrentLoginStatus(): - | 'authenticated' - | 'unauthenticated' - | 'loading' { +export function useCurrentLoginStatus() { const session = useSession(); return session.status; } diff --git a/packages/frontend/core/src/hooks/affine/use-current-user.ts b/packages/frontend/core/src/hooks/affine/use-current-user.ts index 35cbe9bbc3..d4329d9ba6 100644 --- a/packages/frontend/core/src/hooks/affine/use-current-user.ts +++ b/packages/frontend/core/src/hooks/affine/use-current-user.ts @@ -1,42 +1,83 @@ -import { type User } from '@affine/component/auth-components'; -import type { DefaultSession, Session } from 'next-auth'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { getSession, useSession } from 'next-auth/react'; -import { useEffect, useMemo, useReducer } from 'react'; +import { DebugLogger } from '@affine/debug'; +import { getBaseUrl } from '@affine/graphql'; +import { useMemo, useReducer } from 'react'; +import useSWR from 'swr'; import { SessionFetchErrorRightAfterLoginOrSignUp } from '../../unexpected-application-state/errors'; +import { useAsyncCallback } from '../affine-async-hooks'; -export type CheckedUser = User & { +const logger = new DebugLogger('auth'); + +interface User { + id: string; + email: string; + name: string; hasPassword: boolean; - update: ReturnType['update']; + avatarUrl: string | null; + emailVerified: string | null; +} + +export interface Session { + user?: User | null; + status: 'authenticated' | 'unauthenticated' | 'loading'; + reload: () => Promise; +} + +export type CheckedUser = Session['user'] & { + update: (changes?: Partial) => void; }; -declare module 'next-auth' { - interface Session { - user: { - name: string; - email: string; - id: string; - hasPassword: boolean; - } & Omit, 'name' | 'email'>; +export async function getSession( + url: string = getBaseUrl() + '/api/auth/session' +) { + try { + const res = await fetch(url); + + if (res.ok) { + return (await res.json()) as { user?: User | null }; + } + + logger.error('Failed to fetch session', res.statusText); + return { user: null }; + } catch (e) { + logger.error('Failed to fetch session', e); + return { user: null }; } } +export function useSession(): Session { + const { data, mutate, isLoading } = useSWR('session', () => getSession()); + + return { + user: data?.user, + status: isLoading + ? 'loading' + : data?.user + ? 'authenticated' + : 'unauthenticated', + reload: async () => { + return mutate().then(e => { + console.error(e); + }); + }, + }; +} + type UpdateSessionAction = | { type: 'update'; - payload: Session; + payload?: Partial; } | { type: 'fetchError'; payload: null; }; -function updateSessionReducer(prevState: Session, action: UpdateSessionAction) { +function updateSessionReducer(prevState: User, action: UpdateSessionAction) { const { type, payload } = action; switch (type) { case 'update': - return payload; + return { ...prevState, ...payload }; case 'fetchError': return prevState; } @@ -49,11 +90,11 @@ function updateSessionReducer(prevState: Session, action: UpdateSessionAction) { * If network error or API response error, it will use the cached value. */ export function useCurrentUser(): CheckedUser { - const { data, update } = useSession(); + const session = useSession(); - const [session, dispatcher] = useReducer( + const [user, dispatcher] = useReducer( updateSessionReducer, - data, + session.user, firstSession => { if (!firstSession) { // barely possible. @@ -64,10 +105,10 @@ export function useCurrentUser(): CheckedUser { () => { getSession() .then(session => { - if (session) { + if (session.user) { dispatcher({ type: 'update', - payload: session, + payload: session.user, }); } }) @@ -77,35 +118,30 @@ export function useCurrentUser(): CheckedUser { } ); } + return firstSession; } ); - useEffect(() => { - if (data) { + const update = useAsyncCallback( + async (changes?: Partial) => { dispatcher({ type: 'update', - payload: data, + payload: changes, }); - } else { - dispatcher({ - type: 'fetchError', - payload: null, - }); - } - }, [data, update]); - const user = session.user; + await session.reload(); + }, + [dispatcher, session] + ); - return useMemo(() => { - return { - id: user.id, - name: user.name, - email: user.email, - image: user.image, - hasPassword: user?.hasPassword ?? false, + return useMemo( + () => ({ + ...user, update, - }; - // spread the user object to make sure the hook will not be re-rendered when user ref changed but the properties not. - }, [user.id, user.name, user.email, user.image, user.hasPassword, update]); + }), + // only list the things will change as deps + // eslint-disable-next-line react-hooks/exhaustive-deps + [user.id, user.avatarUrl, user.name, update] + ); } diff --git a/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts b/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts index 2a99a4a3cf..e1b3b6764d 100644 --- a/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts +++ b/packages/frontend/core/src/hooks/affine/use-delete-collection-info.ts @@ -1,11 +1,12 @@ -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; import { useMemo } from 'react'; +import { useSession } from './use-current-user'; + export const useDeleteCollectionInfo = () => { - const user = useSession().data?.user; + const { user } = useSession(); + return useMemo( - () => (user ? { userName: user.name ?? '', userId: user.id } : null), + () => (user ? { userName: user.name, userId: user.id } : null), [user] ); }; diff --git a/packages/frontend/core/src/hooks/affine/use-reference-link-helper.ts b/packages/frontend/core/src/hooks/affine/use-reference-link-helper.ts index 6067c906a1..e90d18b7c7 100644 --- a/packages/frontend/core/src/hooks/affine/use-reference-link-helper.ts +++ b/packages/frontend/core/src/hooks/affine/use-reference-link-helper.ts @@ -1,13 +1,11 @@ import { useCallback } from 'react'; -import type { BlockSuiteWorkspace } from '../../shared'; +import type { DocCollection } from '../../shared'; -export function useReferenceLinkHelper( - blockSuiteWorkspace: BlockSuiteWorkspace -) { +export function useReferenceLinkHelper(docCollection: DocCollection) { const addReferenceLink = useCallback( (pageId: string, referenceId: string) => { - const page = blockSuiteWorkspace?.getDoc(pageId); + const page = docCollection?.getDoc(pageId); if (!page) { return; } @@ -26,7 +24,7 @@ export function useReferenceLinkHelper( frame && page.addBlock('affine:paragraph', { text }, frame.id); }, - [blockSuiteWorkspace] + [docCollection] ); return { diff --git a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx index f3919ea2b4..17753fdd30 100644 --- a/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx +++ b/packages/frontend/core/src/hooks/affine/use-register-blocksuite-editor-commands.tsx @@ -24,9 +24,9 @@ export function useRegisterBlocksuiteEditorCommands() { const mode = useLiveData(page.mode); const t = useAFFiNEI18N(); const workspace = useService(Workspace); - const blockSuiteWorkspace = workspace.blockSuiteWorkspace; - const { getDocMeta } = useDocMetaHelper(blockSuiteWorkspace); - const currentPage = blockSuiteWorkspace.getDoc(pageId); + const docCollection = workspace.docCollection; + const { getDocMeta } = useDocMetaHelper(docCollection); + const currentPage = docCollection.getDoc(pageId); assertExists(currentPage); const pageMeta = getDocMeta(pageId); assertExists(pageMeta); @@ -43,9 +43,9 @@ export function useRegisterBlocksuiteEditorCommands() { }, [pageId, setPageHistoryModalState]); const { toggleFavorite, restoreFromTrash, duplicate } = - useBlockSuiteMetaHelper(blockSuiteWorkspace); + useBlockSuiteMetaHelper(docCollection); const exportHandler = useExportPage(currentPage); - const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace); + const { setTrashModal } = useTrashModalHelper(docCollection); const onClickDelete = useCallback(() => { setTrashModal({ open: true, diff --git a/packages/frontend/core/src/hooks/affine/use-server-config.ts b/packages/frontend/core/src/hooks/affine/use-server-config.ts index ef0878d1de..f6cf86146e 100644 --- a/packages/frontend/core/src/hooks/affine/use-server-config.ts +++ b/packages/frontend/core/src/hooks/affine/use-server-config.ts @@ -1,5 +1,5 @@ import type { ServerFeature } from '@affine/graphql'; -import { serverConfigQuery } from '@affine/graphql'; +import { oauthProvidersQuery, serverConfigQuery } from '@affine/graphql'; import type { BareFetcher, Middleware } from 'swr'; import { useQueryImmutable } from '../use-query'; @@ -44,6 +44,21 @@ export const useServerFeatures = (): ServerFeatureRecord => { }, {} as ServerFeatureRecord); }; +export const useOAuthProviders = () => { + const { data, error } = useQueryImmutable( + { query: oauthProvidersQuery }, + { + use: [errorHandler], + } + ); + + if (error || !data) { + return []; + } + + return data.serverConfig.oauthProviders; +}; + export const useServerBaseUrl = () => { const config = useServerConfig(); diff --git a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts index a671985e98..a31f24259c 100644 --- a/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts +++ b/packages/frontend/core/src/hooks/affine/use-sidebar-drag.ts @@ -70,7 +70,7 @@ export function getDragItemId( export const useSidebarDrag = () => { const t = useAFFiNEI18N(); const currentWorkspace = useService(Workspace); - const workspace = currentWorkspace.blockSuiteWorkspace; + const workspace = currentWorkspace.docCollection; const { setTrashModal } = useTrashModalHelper(workspace); const { addToFavorite, removeFromFavorite } = useBlockSuiteMetaHelper(workspace); diff --git a/packages/frontend/core/src/hooks/affine/use-trash-modal-helper.ts b/packages/frontend/core/src/hooks/affine/use-trash-modal-helper.ts index e33dfcffab..ebf530ae0f 100644 --- a/packages/frontend/core/src/hooks/affine/use-trash-modal-helper.ts +++ b/packages/frontend/core/src/hooks/affine/use-trash-modal-helper.ts @@ -1,17 +1,17 @@ import { toast } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { useAtom } from 'jotai'; import { useCallback } from 'react'; import { trashModalAtom } from '../../atoms/trash-modal'; import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper'; -export function useTrashModalHelper(blocksuiteWorkspace: Workspace) { +export function useTrashModalHelper(docCollection: DocCollection) { const t = useAFFiNEI18N(); const [trashModal, setTrashModal] = useAtom(trashModalAtom); const { pageIds } = trashModal; - const { removeToTrash } = useBlockSuiteMetaHelper(blocksuiteWorkspace); + const { removeToTrash } = useBlockSuiteMetaHelper(docCollection); const handleOnConfirm = useCallback(() => { pageIds.forEach(pageId => { diff --git a/packages/frontend/core/src/hooks/affine/use-user-features.ts b/packages/frontend/core/src/hooks/affine/use-user-features.ts new file mode 100644 index 0000000000..505e04a2ff --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-user-features.ts @@ -0,0 +1,24 @@ +import { FeatureType, getUserFeaturesQuery } from '@affine/graphql'; +import type { BareFetcher, Middleware } from 'swr'; + +import { useQueryImmutable } from '../use-query'; + +const wrappedFetcher = (fetcher: BareFetcher | null, ...args: any[]) => + fetcher?.(...args).catch(() => null); + +const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => { + return useSWRNext(key, wrappedFetcher.bind(null, fetcher), config); +}; + +export function useIsEarlyAccess() { + const { data } = useQueryImmutable( + { + query: getUserFeaturesQuery, + }, + { + use: [errorHandler], + } + ); + + return data?.currentUser?.features.includes(FeatureType.EarlyAccess) ?? false; +} diff --git a/packages/frontend/core/src/hooks/use-affine-adapter.ts b/packages/frontend/core/src/hooks/use-affine-adapter.ts index d69af330c5..deec59baf0 100644 --- a/packages/frontend/core/src/hooks/use-affine-adapter.ts +++ b/packages/frontend/core/src/hooks/use-affine-adapter.ts @@ -20,8 +20,7 @@ const useReactiveAdapter = (adapter: WorkspacePropertiesAdapter) => { } const disposables: (() => void)[] = []; disposables.push( - adapter.workspace.blockSuiteWorkspace.meta.docMetaUpdated.on(observe) - .dispose + adapter.workspace.docCollection.meta.docMetaUpdated.on(observe).dispose ); adapter.properties.observeDeep(observe); disposables.push(() => adapter.properties.unobserveDeep(observe)); diff --git a/packages/frontend/core/src/hooks/use-all-block-suite-page-meta.ts b/packages/frontend/core/src/hooks/use-all-block-suite-page-meta.ts index a775a22552..5282d5baaf 100644 --- a/packages/frontend/core/src/hooks/use-all-block-suite-page-meta.ts +++ b/packages/frontend/core/src/hooks/use-all-block-suite-page-meta.ts @@ -1,25 +1,25 @@ -import type { DocMeta, Workspace } from '@blocksuite/store'; +import type { DocCollection, DocMeta } from '@blocksuite/store'; import type { Atom } from 'jotai'; import { atom, useAtomValue } from 'jotai'; -const weakMap = new WeakMap>(); +const weakMap = new WeakMap>(); // this hook is extracted from './use-block-suite-page-meta.ts' to avoid circular dependency export function useAllBlockSuiteDocMeta( - blockSuiteWorkspace: Workspace + docCollection: DocCollection ): DocMeta[] { - if (!weakMap.has(blockSuiteWorkspace)) { - const baseAtom = atom(blockSuiteWorkspace.meta.docMetas); - weakMap.set(blockSuiteWorkspace, baseAtom); + if (!weakMap.has(docCollection)) { + const baseAtom = atom(docCollection.meta.docMetas); + weakMap.set(docCollection, baseAtom); baseAtom.onMount = set => { - set(blockSuiteWorkspace.meta.docMetas); - const dispose = blockSuiteWorkspace.meta.docMetaUpdated.on(() => { - set(blockSuiteWorkspace.meta.docMetas); + set(docCollection.meta.docMetas); + const dispose = docCollection.meta.docMetaUpdated.on(() => { + set(docCollection.meta.docMetas); }); return () => { dispose.dispose(); }; }; } - return useAtomValue(weakMap.get(blockSuiteWorkspace) as Atom); + return useAtomValue(weakMap.get(docCollection) as Atom); } diff --git a/packages/frontend/core/src/hooks/use-block-suite-page-backlinks.ts b/packages/frontend/core/src/hooks/use-block-suite-page-backlinks.ts index 9e2bcda3c6..13d1d81d9f 100644 --- a/packages/frontend/core/src/hooks/use-block-suite-page-backlinks.ts +++ b/packages/frontend/core/src/hooks/use-block-suite-page-backlinks.ts @@ -1,11 +1,11 @@ -import type { Doc, Workspace } from '@blocksuite/store'; +import type { Doc, DocCollection } from '@blocksuite/store'; import { type Atom, atom, useAtomValue } from 'jotai'; -import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page'; +import { useDocCollectionPage } from './use-block-suite-workspace-page'; const weakMap = new WeakMap>(); function getPageBacklinks(page: Doc): string[] { - return page.workspace.indexer.backlink + return page.collection.indexer.backlink .getBacklink(page.id) .map(linkNode => linkNode.pageId) .filter(id => id !== page.id); @@ -23,7 +23,7 @@ const getPageBacklinksAtom = (page: Doc | null) => { page.slots.ready.on(() => { set(getPageBacklinks(page)); }), - page.workspace.indexer.backlink.slots.indexUpdated.on(() => { + page.collection.indexer.backlink.slots.indexUpdated.on(() => { set(getPageBacklinks(page)); }), ]; @@ -38,9 +38,9 @@ const getPageBacklinksAtom = (page: Doc | null) => { }; export function useBlockSuitePageBacklinks( - blockSuiteWorkspace: Workspace, - pageId: string + docCollection: DocCollection, + docId: string ): string[] { - const page = useBlockSuiteWorkspacePage(blockSuiteWorkspace, pageId); - return useAtomValue(getPageBacklinksAtom(page)); + const doc = useDocCollectionPage(docCollection, docId); + return useAtomValue(getPageBacklinksAtom(doc)); } diff --git a/packages/frontend/core/src/hooks/use-block-suite-page-meta.ts b/packages/frontend/core/src/hooks/use-block-suite-page-meta.ts index df943935f9..6a4ac6fca8 100644 --- a/packages/frontend/core/src/hooks/use-block-suite-page-meta.ts +++ b/packages/frontend/core/src/hooks/use-block-suite-page-meta.ts @@ -1,6 +1,6 @@ import type { RootBlockModel } from '@blocksuite/blocks'; import { assertExists } from '@blocksuite/global/utils'; -import type { DocMeta, Workspace } from '@blocksuite/store'; +import type { DocCollection, DocMeta } from '@blocksuite/store'; import { useMemo } from 'react'; import { useAllBlockSuiteDocMeta } from './use-all-block-suite-page-meta'; @@ -11,9 +11,9 @@ import { useJournalHelper } from './use-journal'; * If you want to get all pageMetas, use `useAllBlockSuitePageMeta` instead * @returns */ -export function useBlockSuiteDocMeta(blocksuiteWorkspace: Workspace) { - const pageMetas = useAllBlockSuiteDocMeta(blocksuiteWorkspace); - const { isPageJournal } = useJournalHelper(blocksuiteWorkspace); +export function useBlockSuiteDocMeta(docCollection: DocCollection) { + const pageMetas = useAllBlockSuiteDocMeta(docCollection); + const { isPageJournal } = useJournalHelper(docCollection); return useMemo( () => pageMetas.filter( @@ -23,11 +23,11 @@ export function useBlockSuiteDocMeta(blocksuiteWorkspace: Workspace) { ); } -export function useDocMetaHelper(blockSuiteWorkspace: Workspace) { +export function useDocMetaHelper(docCollection: DocCollection) { return useMemo( () => ({ setDocTitle: (docId: string, newTitle: string) => { - const page = blockSuiteWorkspace.getDoc(docId); + const page = docCollection.getDoc(docId); assertExists(page); const pageBlock = page .getBlockByFlavour('affine:page') @@ -37,20 +37,20 @@ export function useDocMetaHelper(blockSuiteWorkspace: Workspace) { pageBlock.title.delete(0, pageBlock.title.length); pageBlock.title.insert(newTitle, 0); }); - blockSuiteWorkspace.meta.setDocMeta(docId, { title: newTitle }); + docCollection.meta.setDocMeta(docId, { title: newTitle }); }, setDocReadonly: (docId: string, readonly: boolean) => { - const page = blockSuiteWorkspace.getDoc(docId); + const page = docCollection.getDoc(docId); assertExists(page); page.awarenessStore.setReadonly(page, readonly); }, setDocMeta: (docId: string, docMeta: Partial) => { - blockSuiteWorkspace.meta.setDocMeta(docId, docMeta); + docCollection.meta.setDocMeta(docId, docMeta); }, getDocMeta: (docId: string) => { - return blockSuiteWorkspace.meta.getDocMeta(docId); + return docCollection.meta.getDocMeta(docId); }, }), - [blockSuiteWorkspace] + [docCollection] ); } diff --git a/packages/frontend/core/src/hooks/use-block-suite-page-references.ts b/packages/frontend/core/src/hooks/use-block-suite-page-references.ts index f86fd47f60..ea9ab92313 100644 --- a/packages/frontend/core/src/hooks/use-block-suite-page-references.ts +++ b/packages/frontend/core/src/hooks/use-block-suite-page-references.ts @@ -1,12 +1,12 @@ -import type { Doc, Workspace } from '@blocksuite/store'; +import type { Doc, DocCollection } from '@blocksuite/store'; import { type Atom, atom, useAtomValue } from 'jotai'; -import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page'; +import { useDocCollectionPage } from './use-block-suite-workspace-page'; const weakMap = new WeakMap>(); function getPageReferences(page: Doc): string[] { return Object.values( - page.workspace.indexer.backlink.linkIndexMap[page.id] ?? {} + page.collection.indexer.backlink.linkIndexMap[page.id] ?? {} ).flatMap(linkNodes => linkNodes.map(linkNode => linkNode.pageId)); } @@ -22,7 +22,7 @@ const getPageReferencesAtom = (page: Doc | null) => { page.slots.ready.on(() => { set(getPageReferences(page)); }), - page.workspace.indexer.backlink.slots.indexUpdated.on(() => { + page.collection.indexer.backlink.slots.indexUpdated.on(() => { set(getPageReferences(page)); }), ]; @@ -37,9 +37,9 @@ const getPageReferencesAtom = (page: Doc | null) => { }; export function useBlockSuitePageReferences( - blockSuiteWorkspace: Workspace, + docCollection: DocCollection, pageId: string ): string[] { - const page = useBlockSuiteWorkspacePage(blockSuiteWorkspace, pageId); + const page = useDocCollectionPage(docCollection, pageId); return useAtomValue(getPageReferencesAtom(page)); } diff --git a/packages/frontend/core/src/hooks/use-block-suite-workspace-helper.ts b/packages/frontend/core/src/hooks/use-block-suite-workspace-helper.ts index 17faf3bec4..a27d08b54c 100644 --- a/packages/frontend/core/src/hooks/use-block-suite-workspace-helper.ts +++ b/packages/frontend/core/src/hooks/use-block-suite-workspace-helper.ts @@ -1,13 +1,13 @@ -import type { Doc, Workspace } from '@blocksuite/store'; +import type { Doc, DocCollection } from '@blocksuite/store'; import { useMemo } from 'react'; -export function useBlockSuiteWorkspaceHelper(blockSuiteWorkspace: Workspace) { +export function useDocCollectionHelper(docCollection: DocCollection) { return useMemo( () => ({ createDoc: (pageId?: string): Doc => { - return blockSuiteWorkspace.createDoc({ id: pageId }); + return docCollection.createDoc({ id: pageId }); }, }), - [blockSuiteWorkspace] + [docCollection] ); } diff --git a/packages/frontend/core/src/hooks/use-block-suite-workspace-page-title.ts b/packages/frontend/core/src/hooks/use-block-suite-workspace-page-title.ts index c4f6d915ce..d0ba731ecb 100644 --- a/packages/frontend/core/src/hooks/use-block-suite-workspace-page-title.ts +++ b/packages/frontend/core/src/hooks/use-block-suite-workspace-page-title.ts @@ -1,14 +1,14 @@ import { assertExists } from '@blocksuite/global/utils'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import type { Atom } from 'jotai'; import { atom, useAtomValue } from 'jotai'; import { useCallback } from 'react'; import { useJournalHelper, useJournalInfoHelper } from './use-journal'; -const weakMap = new WeakMap>>(); +const weakMap = new WeakMap>>(); -function getAtom(w: Workspace, pageId: string): Atom { +function getAtom(w: DocCollection, pageId: string): Atom { if (!weakMap.has(w)) { weakMap.set(w, new Map()); } @@ -32,33 +32,27 @@ function getAtom(w: Workspace, pageId: string): Atom { } } -export function useBlockSuiteWorkspacePageTitle( - blockSuiteWorkspace: Workspace, +export function useDocCollectionPageTitle( + docCollection: DocCollection, pageId: string ) { - const titleAtom = getAtom(blockSuiteWorkspace, pageId); + const titleAtom = getAtom(docCollection, pageId); assertExists(titleAtom); const title = useAtomValue(titleAtom); - const { localizedJournalDate } = useJournalInfoHelper( - blockSuiteWorkspace, - pageId - ); + const { localizedJournalDate } = useJournalInfoHelper(docCollection, pageId); return localizedJournalDate || title; } // This hook is NOT reactive to the page title change -export function useGetBlockSuiteWorkspacePageTitle( - blockSuiteWorkspace: Workspace -) { - const { getLocalizedJournalDateString } = - useJournalHelper(blockSuiteWorkspace); +export function useGetDocCollectionPageTitle(docCollection: DocCollection) { + const { getLocalizedJournalDateString } = useJournalHelper(docCollection); return useCallback( (pageId: string) => { return ( getLocalizedJournalDateString(pageId) || - blockSuiteWorkspace.getDoc(pageId)?.meta?.title + docCollection.getDoc(pageId)?.meta?.title ); }, - [blockSuiteWorkspace, getLocalizedJournalDateString] + [docCollection, getLocalizedJournalDateString] ); } diff --git a/packages/frontend/core/src/hooks/use-block-suite-workspace-page.ts b/packages/frontend/core/src/hooks/use-block-suite-workspace-page.ts index c6f2a4785d..618ca0b865 100644 --- a/packages/frontend/core/src/hooks/use-block-suite-workspace-page.ts +++ b/packages/frontend/core/src/hooks/use-block-suite-workspace-page.ts @@ -1,26 +1,26 @@ import { DisposableGroup } from '@blocksuite/global/utils'; -import type { Doc, Workspace } from '@blocksuite/store'; +import type { Doc, DocCollection } from '@blocksuite/store'; import { useEffect, useState } from 'react'; -export function useBlockSuiteWorkspacePage( - blockSuiteWorkspace: Workspace, +export function useDocCollectionPage( + docCollection: DocCollection, pageId: string | null ): Doc | null { const [page, setPage] = useState( - pageId ? blockSuiteWorkspace.getDoc(pageId) : null + pageId ? docCollection.getDoc(pageId) : null ); useEffect(() => { const group = new DisposableGroup(); group.add( - blockSuiteWorkspace.slots.docAdded.on(id => { + docCollection.slots.docAdded.on(id => { if (pageId === id) { - setPage(blockSuiteWorkspace.getDoc(id)); + setPage(docCollection.getDoc(id)); } }) ); group.add( - blockSuiteWorkspace.slots.docRemoved.on(id => { + docCollection.slots.docRemoved.on(id => { if (pageId === id) { setPage(null); } @@ -29,7 +29,7 @@ export function useBlockSuiteWorkspacePage( return () => { group.dispose(); }; - }, [blockSuiteWorkspace, pageId]); + }, [docCollection, pageId]); useEffect(() => { if (page && !page.loaded) { diff --git a/packages/frontend/core/src/hooks/use-journal.ts b/packages/frontend/core/src/hooks/use-journal.ts index 695456eac0..b63ce1a5a8 100644 --- a/packages/frontend/core/src/hooks/use-journal.ts +++ b/packages/frontend/core/src/hooks/use-journal.ts @@ -2,10 +2,10 @@ import { initEmptyPage } from '@toeverything/infra'; import dayjs from 'dayjs'; import { useCallback, useMemo } from 'react'; -import type { BlockSuiteWorkspace } from '../shared'; +import type { DocCollection } from '../shared'; import { timestampToLocalDate } from '../utils'; import { useCurrentWorkspacePropertiesAdapter } from './use-affine-adapter'; -import { useBlockSuiteWorkspaceHelper } from './use-block-suite-workspace-helper'; +import { useDocCollectionHelper } from './use-block-suite-workspace-helper'; import { useNavigateHelper } from './use-navigate-helper'; type MaybeDate = Date | string | number; @@ -22,8 +22,8 @@ function toDayjs(j?: string | false) { return day; } -export const useJournalHelper = (workspace: BlockSuiteWorkspace) => { - const bsWorkspaceHelper = useBlockSuiteWorkspaceHelper(workspace); +export const useJournalHelper = (docCollection: DocCollection) => { + const bsWorkspaceHelper = useDocCollectionHelper(docCollection); const adapter = useCurrentWorkspacePropertiesAdapter(); /** @@ -35,7 +35,7 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => { const title = day.format(JOURNAL_DATE_FORMAT); const page = bsWorkspaceHelper.createDoc(); // set created date to match the journal date - page.workspace.setDocMeta(page.id, { + page.collection.setDocMeta(page.id, { createDate: dayjs() .set('year', day.year()) .set('month', day.month()) @@ -63,7 +63,7 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => { const getJournalsByDate = useCallback( (maybeDate: MaybeDate) => { const day = dayjs(maybeDate); - return Array.from(workspace.docs.values()).filter(page => { + return Array.from(docCollection.docs.values()).filter(page => { const pageId = page.id; if (!isPageJournal(pageId)) return false; if (page.meta?.trash) return false; @@ -72,7 +72,7 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => { return day.isSame(journalDate, 'day'); }); }, - [adapter, isPageJournal, workspace.docs] + [adapter, isPageJournal, docCollection.docs] ); /** @@ -150,18 +150,18 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => { }; // split useJournalRouteHelper since it requires a context, which may not work in lit -export const useJournalRouteHelper = (workspace: BlockSuiteWorkspace) => { +export const useJournalRouteHelper = (docCollection: DocCollection) => { const navigateHelper = useNavigateHelper(); - const { getJournalByDate } = useJournalHelper(workspace); + const { getJournalByDate } = useJournalHelper(docCollection); /** * open journal by date, create one if not exist */ const openJournal = useCallback( (maybeDate: MaybeDate) => { const page = getJournalByDate(maybeDate); - navigateHelper.openPage(workspace.id, page.id); + navigateHelper.openPage(docCollection.id, page.id); }, - [getJournalByDate, navigateHelper, workspace.id] + [getJournalByDate, navigateHelper, docCollection.id] ); /** @@ -182,7 +182,7 @@ export const useJournalRouteHelper = (workspace: BlockSuiteWorkspace) => { }; export const useJournalInfoHelper = ( - workspace: BlockSuiteWorkspace, + docCollection: DocCollection, pageId?: string | null ) => { const { @@ -190,7 +190,7 @@ export const useJournalInfoHelper = ( getJournalDateString, getLocalizedJournalDateString, isPageTodayJournal, - } = useJournalHelper(workspace); + } = useJournalHelper(docCollection); return useMemo( () => ({ diff --git a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts index e29252e3f4..03022575dd 100644 --- a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts @@ -24,7 +24,7 @@ export function useRegisterWorkspaceCommands() { const theme = useTheme(); const currentWorkspace = useService(Workspace); const languageHelper = useLanguageHelper(); - const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace); + const pageHelper = usePageHelper(currentWorkspace.docCollection); const navigationHelper = useNavigateHelper(); const [editor] = useActiveBlocksuiteEditor(); @@ -45,14 +45,14 @@ export function useRegisterWorkspaceCommands() { const unsub = registerAffineNavigationCommands({ store, t, - workspace: currentWorkspace.blockSuiteWorkspace, + docCollection: currentWorkspace.docCollection, navigationHelper, }); return () => { unsub(); }; - }, [store, t, currentWorkspace.blockSuiteWorkspace, navigationHelper]); + }, [store, t, currentWorkspace.docCollection, navigationHelper]); // register AffineSettingsCommands useEffect(() => { diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 595c1aa2af..c6d9e8afa7 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -94,7 +94,7 @@ export const WorkspaceLayout = function WorkspaceLayout({ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { const currentWorkspace = useService(Workspace); const { openPage } = useNavigateHelper(); - const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace); + const pageHelper = usePageHelper(currentWorkspace.docCollection); useRegisterWorkspaceCommands(); @@ -104,7 +104,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { // 0.8.0 ~ 0.8.1 // 0.8.0-beta.0 ~ 0.8.0-beta.3 // 0.8.0-canary.17 ~ 0.9.0-canary.3 - const meta = currentWorkspace.blockSuiteWorkspace.doc.getMap('meta'); + const meta = currentWorkspace.docCollection.doc.getMap('meta'); const blockVersions = meta.get('blockVersions'); if ( !(blockVersions instanceof YMap) && @@ -117,7 +117,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { new YMap(Object.entries(blockVersions as Record)) ); } - }, [currentWorkspace.blockSuiteWorkspace.doc]); + }, [currentWorkspace.docCollection.doc]); const handleCreatePage = useCallback(() => { return pageHelper.createPage(); diff --git a/packages/frontend/core/src/modules/collection/service.ts b/packages/frontend/core/src/modules/collection/service.ts index 7f69caa2fc..374aa99732 100644 --- a/packages/frontend/core/src/modules/collection/service.ts +++ b/packages/frontend/core/src/modules/collection/service.ts @@ -17,11 +17,11 @@ export class CollectionService { constructor(private readonly workspace: Workspace) {} private get doc() { - return this.workspace.blockSuiteWorkspace.doc; + return this.workspace.docCollection.doc; } private get setting() { - return this.workspace.blockSuiteWorkspace.doc.getMap(SETTING_KEY); + return this.workspace.docCollection.doc.getMap(SETTING_KEY); } private get collectionsYArray(): YArray | undefined { @@ -96,7 +96,7 @@ export class CollectionService { return; } const set = new Set(ids); - this.workspace.blockSuiteWorkspace.doc.transact(() => { + this.workspace.docCollection.doc.transact(() => { const indexList: number[] = []; const list: Collection[] = []; collectionsYArray.forEach((collection, i) => { diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.tsx index 371fd3a2c4..c32a8292ea 100644 --- a/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.tsx +++ b/packages/frontend/core/src/modules/multi-tab-sidebar/entities/tabs/journal.tsx @@ -61,7 +61,7 @@ const PageItem = ({ const mode = useLiveData(pageRecord.mode); const workspace = useService(Workspace); const { isJournal } = useJournalInfoHelper( - workspace.blockSuiteWorkspace, + workspace.docCollection, pageRecord.id ); @@ -100,10 +100,10 @@ const EditorJournalPanel = () => { const doc = useService(Doc); const workspace = useService(Workspace); const { journalDate, isJournal } = useJournalInfoHelper( - workspace.blockSuiteWorkspace, + workspace.docCollection, doc.id ); - const { openJournal } = useJournalRouteHelper(workspace.blockSuiteWorkspace); + const { openJournal } = useJournalRouteHelper(workspace.docCollection); const [date, setDate] = useState(dayjs().format('YYYY-MM-DD')); useEffect(() => { @@ -314,7 +314,7 @@ const ConflictList = ({ const navigateHelper = useNavigateHelper(); const workspace = useService(Workspace); const currentDoc = useService(Doc); - const { setTrashModal } = useTrashModalHelper(workspace.blockSuiteWorkspace); + const { setTrashModal } = useTrashModalHelper(workspace.docCollection); const handleOpenTrashModal = useCallback( (pageRecord: PageRecord) => { @@ -361,7 +361,7 @@ const JournalConflictBlock = ({ date }: JournalBlockProps) => { const t = useAFFiNEI18N(); const workspace = useService(Workspace); const pageRecordList = useService(PageRecordList); - const journalHelper = useJournalHelper(workspace.blockSuiteWorkspace); + const journalHelper = useJournalHelper(workspace.docCollection); const docs = journalHelper.getJournalsByDate(date.format('YYYY-MM-DD')); const pageRecords = useLiveData(pageRecordList.records).filter(v => { return docs.some(doc => doc.id === v.id); diff --git a/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx b/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx index cb6a4eed6c..90bfabbecf 100644 --- a/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx +++ b/packages/frontend/core/src/modules/multi-tab-sidebar/view/header-switcher.tsx @@ -28,10 +28,7 @@ export const MultiTabSidebarHeaderSwitcher = ({ FeatureType.Copilot ); - const { isJournal } = useJournalInfoHelper( - workspace.blockSuiteWorkspace, - doc.id - ); + const { isJournal } = useJournalInfoHelper(workspace.docCollection, doc.id); const exts = useMemo( () => diff --git a/packages/frontend/core/src/modules/right-sidebar/view/container.css.ts b/packages/frontend/core/src/modules/right-sidebar/view/container.css.ts index 72918888ac..a7be1f52e2 100644 --- a/packages/frontend/core/src/modules/right-sidebar/view/container.css.ts +++ b/packages/frontend/core/src/modules/right-sidebar/view/container.css.ts @@ -8,15 +8,24 @@ export const sidebarContainerInner = style({ overflow: 'hidden', height: '100%', width: '100%', + borderRadius: 'inherit', + selectors: { + ['[data-client-border=true][data-is-floating="true"] &']: { + boxShadow: cssVar('shadow3'), + border: `1px solid ${cssVar('borderColor')}`, + }, + }, }); export const sidebarContainer = style({ display: 'flex', flexShrink: 0, height: '100%', + right: 0, selectors: { [`&[data-client-border=true]`]: { - paddingLeft: 9, + paddingLeft: 8, + borderRadius: 6, }, [`&[data-client-border=false]`]: { borderLeft: `1px solid ${cssVar('borderColor')}`, diff --git a/packages/frontend/core/src/modules/right-sidebar/view/container.tsx b/packages/frontend/core/src/modules/right-sidebar/view/container.tsx index 1946a7fb4e..f27d5e7109 100644 --- a/packages/frontend/core/src/modules/right-sidebar/view/container.tsx +++ b/packages/frontend/core/src/modules/right-sidebar/view/container.tsx @@ -1,9 +1,10 @@ import { ResizePanel } from '@affine/component/resize-panel'; +import { appSidebarOpenAtom } from '@affine/core/components/app-sidebar'; import { appSettingAtom } from '@toeverything/infra/atom'; import { useService } from '@toeverything/infra/di'; import { useLiveData } from '@toeverything/infra/livedata'; import { useAtomValue } from 'jotai'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { RightSidebar } from '../entities/right-sidebar'; import * as styles from './container.css'; @@ -20,6 +21,18 @@ export const RightSidebarContainer = () => { const frontView = useLiveData(rightSidebar.front); const open = useLiveData(rightSidebar.isOpen) && frontView !== undefined; + const [floating, setFloating] = useState(false); + const appSidebarOpened = useAtomValue(appSidebarOpenAtom); + + useEffect(() => { + const onResize = () => + setFloating(!!(window.innerWidth < 1200 && appSidebarOpened)); + onResize(); + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + }; + }, [appSidebarOpened]); const handleOpenChange = useCallback( (open: boolean) => { @@ -38,8 +51,9 @@ export const RightSidebarContainer = () => { return ( void; className: string; + show: boolean; }) => { return ( - + ); @@ -49,16 +57,22 @@ export const RouteContainer = ({ route }: Props) => { return (
- {viewPosition.isFirst && !leftSidebarOpen && ( - + {viewPosition.isFirst && ( + )} - {viewPosition.isLast && !rightSidebarOpen && rightSidebarHasViews && ( + {viewPosition.isLast && ( <> - + {rightSidebarHasViews && ( + + )} {isWindowsDesktop && (
@@ -68,9 +82,11 @@ export const RouteContainer = ({ route }: Props) => { )}
- loading}> - - + + + + +
); }; diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts index 4d9a5a1bb5..9a2755ea94 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts +++ b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.css.ts @@ -1,13 +1,35 @@ import { cssVar } from '@toeverything/theme'; import { style } from '@vanilla-extract/css'; +export const indicatorWrapper = style({ + position: 'absolute', + zIndex: 4, + top: 0, + left: '50%', + transform: 'translateX(-50%)', + width: '50%', + maxWidth: 300, + minWidth: 120, + height: 15, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + ['WebkitAppRegion' as string]: 'no-drag', +}); + +export const menuTrigger = style({ + position: 'absolute', + width: 0, + height: 0, + pointerEvents: 'none', +}); export const indicator = style({ width: 29, - height: 14, + height: 15, display: 'flex', justifyContent: 'center', alignItems: 'center', - cursor: 'pointer', + cursor: 'grab', ['WebkitAppRegion' as string]: 'no-drag', color: cssVar('placeholderColor'), @@ -19,8 +41,16 @@ export const indicator = style({ }); export const indicatorInner = style({ - width: 15, + width: 16, height: 3, borderRadius: 10, backgroundColor: 'currentColor', + transition: 'all 0.5s cubic-bezier(0.16, 1, 0.3, 1)', + + selectors: { + '[data-is-dragging="true"] &': { + width: 24, + height: 2, + }, + }, }); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx index 8cb53c4b64..a5d30aef63 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/indicator.tsx @@ -1,23 +1,61 @@ +import { Menu, type MenuProps } from '@affine/component'; import clsx from 'clsx'; -import { forwardRef, type HTMLAttributes, memo } from 'react'; +import { + forwardRef, + type HTMLAttributes, + memo, + type MouseEventHandler, + useCallback, + useMemo, + useState, +} from 'react'; import * as styles from './indicator.css'; export interface SplitViewMenuProps extends HTMLAttributes { active?: boolean; + open?: boolean; + onOpenMenu?: () => void; + setPressed: (v: boolean) => void; } export const SplitViewMenuIndicator = memo( forwardRef( function SplitViewMenuIndicator( - { className, active, ...attrs }: SplitViewMenuProps, + { + className, + active, + open, + setPressed, + onOpenMenu, + ...attrs + }: SplitViewMenuProps, ref ) { + // dnd's `isDragging` changes after mouseDown and mouseMoved + const onMouseDown = useCallback(() => { + const t = setTimeout(() => setPressed(true), 100); + window.addEventListener( + 'mouseup', + () => { + clearTimeout(t); + setPressed(false); + }, + { once: true } + ); + }, [setPressed]); + + const onClick: MouseEventHandler = useCallback(() => { + !open && onOpenMenu?.(); + }, [onOpenMenu, open]); + return (
@@ -26,3 +64,66 @@ export const SplitViewMenuIndicator = memo( } ) ); + +interface SplitViewIndicatorProps extends HTMLAttributes { + isDragging?: boolean; + isActive?: boolean; + menuItems?: React.ReactNode; + // import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities' is not allowed + listeners?: any; + setPressed?: (pressed: boolean) => void; +} +export const SplitViewIndicator = ({ + isDragging, + isActive, + menuItems, + listeners, + setPressed, +}: SplitViewIndicatorProps) => { + const active = isActive || isDragging; + const [menuOpen, setMenuOpen] = useState(false); + + // prevent menu from opening when dragging + const setOpenMenuManually = useCallback((open: boolean) => { + if (open) return; + setMenuOpen(open); + }, []); + const openMenu = useCallback(() => { + setMenuOpen(true); + }, []); + + const menuRootOptions = useMemo( + () => + ({ + open: menuOpen, + onOpenChange: setOpenMenuManually, + }) satisfies MenuProps['rootOptions'], + [menuOpen, setOpenMenuManually] + ); + const menuContentOptions = useMemo( + () => + ({ + align: 'center', + }) satisfies MenuProps['contentOptions'], + [] + ); + + return ( +
+ +
+
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx index bbe7787c45..3ab80455b9 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/panel.tsx @@ -1,10 +1,10 @@ -import { Menu, MenuIcon, MenuItem, type MenuProps } from '@affine/component'; +import { MenuIcon, MenuItem } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { - CloseIcon, - ExpandFullIcon, - InsertLeftIcon, - InsertRightIcon, + ExpandCloseIcon, + KeepThisOneIcon, + MoveToLeftIcon, + MoveToRightIcon, } from '@blocksuite/icons'; import { useSortable } from '@dnd-kit/sortable'; import { useService } from '@toeverything/infra/di'; @@ -26,7 +26,7 @@ import { import type { View } from '../../entities/view'; import { Workbench } from '../../entities/workbench'; -import { SplitViewMenuIndicator } from './indicator'; +import { SplitViewIndicator } from './indicator'; import * as styles from './split-view.css'; export interface SplitViewPanelProps @@ -43,22 +43,24 @@ export const SplitViewPanel = memo(function SplitViewPanel({ view, setSlots, }: SplitViewPanelProps) { + const [indicatorPressed, setIndicatorPressed] = useState(false); const ref = useRef(null); const size = useLiveData(view.size); - const [menuOpen, setMenuOpen] = useState(false); const workbench = useService(Workbench); const activeView = useLiveData(workbench.activeView); const views = useLiveData(workbench.views); + const isLast = views[views.length - 1] === view; + const { attributes, listeners, transform, transition, - isDragging, + isDragging: dndIsDragging, setNodeRef, - setActivatorNodeRef, } = useSortable({ id: view.id, attributes: { role: 'group' } }); + const isDragging = dndIsDragging || indicatorPressed; const isActive = activeView === view; useEffect(() => { @@ -67,12 +69,6 @@ export const SplitViewPanel = memo(function SplitViewPanel({ } }, [setSlots, view.id]); - useEffect(() => { - if (isDragging) { - setMenuOpen(false); - } - }, [isDragging]); - const style = useMemo( () => ({ ...assignInlineVars({ '--size': size.toString() }), @@ -86,27 +82,14 @@ export const SplitViewPanel = memo(function SplitViewPanel({ }), [transform, transition] ); - const menuRootOptions = useMemo( - () => - ({ - open: menuOpen, - onOpenChange: setMenuOpen, - }) satisfies MenuProps['rootOptions'], - [menuOpen] - ); - const menuContentOptions = useMemo( - () => - ({ - align: 'center', - }) satisfies MenuProps['contentOptions'], - [] - ); return (
1} + data-is-last={isLast} >
{views.length > 1 ? ( - } - rootOptions={menuRootOptions} - > - - + } + setPressed={setIndicatorPressed} + /> ) : null}
{children} @@ -135,10 +113,7 @@ export const SplitViewPanel = memo(function SplitViewPanel({ ); }); -interface SplitViewMenuProps { - view: View; -} -const SplitViewMenu = ({ view }: SplitViewMenuProps) => { +const SplitViewMenu = ({ view }: { view: View }) => { const t = useAFFiNEI18N(); const workbench = useService(Workbench); const views = useLiveData(workbench.views); @@ -155,14 +130,14 @@ const SplitViewMenu = ({ view }: SplitViewMenuProps) => { const handleMoveRight = useCallback(() => { workbench.moveView(viewIndex, viewIndex + 1); }, [viewIndex, workbench]); - const handleFullScreen = useCallback(() => { + const handleCloseOthers = useCallback(() => { workbench.closeOthers(view); }, [view, workbench]); const CloseItem = views.length > 1 ? ( } />} + preFix={} />} onClick={handleClose} > {t['com.affine.workbench.split-view-menu.close']()} @@ -173,7 +148,7 @@ const SplitViewMenu = ({ view }: SplitViewMenuProps) => { viewIndex > 0 && views.length > 1 ? ( } />} + preFix={} />} > {t['com.affine.workbench.split-view-menu.move-left']()} @@ -182,10 +157,10 @@ const SplitViewMenu = ({ view }: SplitViewMenuProps) => { const FullScreenItem = views.length > 1 ? ( } />} + onClick={handleCloseOthers} + preFix={} />} > - {t['com.affine.workbench.split-view-menu.full-screen']()} + {t['com.affine.workbench.split-view-menu.keep-this-one']()} ) : null; @@ -193,7 +168,7 @@ const SplitViewMenu = ({ view }: SplitViewMenuProps) => { viewIndex < views.length - 1 ? ( } />} + preFix={} />} > {t['com.affine.workbench.split-view-menu.move-right']()} diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts index a7b579b60a..5aff5907e5 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts +++ b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.css.ts @@ -17,7 +17,7 @@ export const splitViewRoot = style({ selectors: { '&[data-client-border="true"]': { vars: { - [gap]: '6px', + [gap]: '8px', [borderRadius]: '6px', }, }, @@ -40,7 +40,7 @@ export const splitViewPanel = style({ '[data-orientation="horizontal"] &': { width: 0, }, - '[data-client-border="false"] &:not(:last-child):not([data-is-dragging="true"])': + '[data-client-border="false"] &:not([data-is-last="true"]):not([data-is-dragging="true"])': { borderRight: `1px solid ${cssVar('borderColor')}`, }, @@ -63,6 +63,10 @@ export const splitViewPanelDrag = style({ borderRadius: 'inherit', pointerEvents: 'none', zIndex: 10, + + // animate border in/out + boxShadow: `inset 0 0 0 0 transparent`, + transition: 'box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1)', }, '[data-is-dragging="true"] &::after': { @@ -125,11 +129,3 @@ export const resizeHandle = style({ // TODO }, }); - -export const menuTrigger = style({ - position: 'absolute', - left: '50%', - top: 3, - transform: 'translateX(-50%)', - zIndex: 10, -}); diff --git a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx index 0b68aa0b41..3638026da3 100644 --- a/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx +++ b/packages/frontend/core/src/modules/workbench/view/split-view/split-view.tsx @@ -58,7 +58,7 @@ export const SplitView = ({ const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { - distance: 2, + distance: 0, }, }) ); diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx index 9ad49879ce..af4fe673c3 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-link.tsx @@ -1,3 +1,4 @@ +import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import { useService } from '@toeverything/infra/di'; import type { To } from 'history'; import { useCallback } from 'react'; @@ -13,12 +14,13 @@ export const WorkbenchLink = ({ { to: To } & React.HTMLProps >) => { const workbench = useService(Workbench); + const { appSettings } = useAppSettingHelper(); const handleClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); // TODO: open this when multi view control is implemented if ( - (window as any).enableMultiView && + appSettings.enableMultiView && environment.isDesktop && (event.ctrlKey || event.metaKey) ) { @@ -29,7 +31,7 @@ export const WorkbenchLink = ({ onClick?.(event); }, - [onClick, to, workbench] + [appSettings.enableMultiView, onClick, to, workbench] ); return ( diff --git a/packages/frontend/core/src/modules/workspace/properties/adapter.ts b/packages/frontend/core/src/modules/workspace/properties/adapter.ts index 5d038663e6..53524870fd 100644 --- a/packages/frontend/core/src/modules/workspace/properties/adapter.ts +++ b/packages/frontend/core/src/modules/workspace/properties/adapter.ts @@ -30,7 +30,7 @@ export class WorkspacePropertiesAdapter { constructor(public readonly workspace: Workspace) { // check if properties exists, if not, create one - const rootDoc = workspace.blockSuiteWorkspace.doc; + const rootDoc = workspace.docCollection.doc; this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID); this.proxy = createYProxy(this.properties); @@ -58,8 +58,8 @@ export class WorkspacePropertiesAdapter { source: 'system', type: PagePropertyType.Tags, options: - this.workspace.blockSuiteWorkspace.meta.properties.tags - ?.options ?? [], // better use a one time migration + this.workspace.docCollection.meta.properties.tags?.options ?? + [], // better use a one time migration }, }, }, @@ -89,8 +89,8 @@ export class WorkspacePropertiesAdapter { } // leak some yjs abstraction to modify multiple properties at once - transact = this.workspace.blockSuiteWorkspace.doc.transact.bind( - this.workspace.blockSuiteWorkspace.doc + transact = this.workspace.docCollection.doc.transact.bind( + this.workspace.docCollection.doc ); get schema() { diff --git a/packages/frontend/core/src/modules/workspace/properties/legacy-properties.ts b/packages/frontend/core/src/modules/workspace/properties/legacy-properties.ts index 58b2a541f3..4c0a4f502a 100644 --- a/packages/frontend/core/src/modules/workspace/properties/legacy-properties.ts +++ b/packages/frontend/core/src/modules/workspace/properties/legacy-properties.ts @@ -14,19 +14,18 @@ export class WorkspaceLegacyProperties { } get properties() { - return this.workspace.blockSuiteWorkspace.meta.properties; + return this.workspace.docCollection.meta.properties; } get tagOptions() { return this.properties.tags?.options ?? []; } updateProperties = (properties: DocsPropertiesMeta) => { - this.workspace.blockSuiteWorkspace.meta.setProperties(properties); + this.workspace.docCollection.meta.setProperties(properties); }; subscribe(cb: () => void) { - const disposable = - this.workspace.blockSuiteWorkspace.meta.docMetaUpdated.on(cb); + const disposable = this.workspace.docCollection.meta.docMetaUpdated.on(cb); return disposable.dispose; } @@ -58,10 +57,10 @@ export class WorkspaceLegacyProperties { }; removeTagOption = (id: string) => { - this.workspace.blockSuiteWorkspace.doc.transact(() => { + this.workspace.docCollection.doc.transact(() => { this.updateTagOptions(this.tagOptions.filter(o => o.id !== id)); // need to remove tag from all pages - this.workspace.blockSuiteWorkspace.docs.forEach(doc => { + this.workspace.docCollection.docs.forEach(doc => { const tags = doc.meta?.tags ?? []; if (tags.includes(id)) { this.updatePageTags( @@ -74,7 +73,7 @@ export class WorkspaceLegacyProperties { }; updatePageTags = (pageId: string, tags: string[]) => { - this.workspace.blockSuiteWorkspace.setDocMeta(pageId, { + this.workspace.docCollection.setDocMeta(pageId, { tags, }); }; diff --git a/packages/frontend/core/src/pages/404.tsx b/packages/frontend/core/src/pages/404.tsx index 280a33da82..8d112e5c92 100644 --- a/packages/frontend/core/src/pages/404.tsx +++ b/packages/frontend/core/src/pages/404.tsx @@ -1,7 +1,6 @@ import { NotFoundPage } from '@affine/component/not-found-page'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { useSession } from 'next-auth/react'; import type { ReactElement } from 'react'; import { useCallback, useState } from 'react'; @@ -10,7 +9,7 @@ import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; import { signOutCloud } from '../utils/cloud-utils'; export const PageNotFound = (): ReactElement => { - const { data: session } = useSession(); + const { user } = useSession(); const { jumpToIndex } = useNavigateHelper(); const [open, setOpen] = useState(false); @@ -25,22 +24,12 @@ export const PageNotFound = (): ReactElement => { const onConfirmSignOut = useAsyncCallback(async () => { setOpen(false); - await signOutCloud({ - callbackUrl: '/signIn', - }); + await signOutCloud('/signIn'); }, [setOpen]); return ( <> diff --git a/packages/frontend/core/src/pages/auth.tsx b/packages/frontend/core/src/pages/auth.tsx index c32fe72536..6f27042856 100644 --- a/packages/frontend/core/src/pages/auth.tsx +++ b/packages/frontend/core/src/pages/auth.tsx @@ -12,6 +12,7 @@ import { changeEmailMutation, changePasswordMutation, sendVerifyChangeEmailMutation, + verifyEmailMutation, } from '@affine/graphql'; import { fetcher } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -42,6 +43,7 @@ const authTypeSchema = z.enum([ 'changeEmail', 'confirm-change-email', 'subscription-redirect', + 'verify-email', ]); export const AuthPage = (): ReactElement | null => { @@ -73,12 +75,10 @@ export const AuthPage = (): ReactElement | null => { // FIXME: There is not notification if (res?.sendVerifyChangeEmail) { pushNotification({ - title: t['com.affine.auth.sent.change.email.hint'](), + title: t['com.affine.auth.sent.verify.email.hint'](), type: 'success', }); - } - - if (!res?.sendVerifyChangeEmail) { + } else { pushNotification({ title: t['com.affine.auth.sent.change.email.fail'](), type: 'error', @@ -156,6 +156,9 @@ export const AuthPage = (): ReactElement | null => { case 'subscription-redirect': { return ; } + case 'verify-email': { + return ; + } } return null; }; @@ -171,20 +174,37 @@ export const loader: LoaderFunction = async args => { if (args.params.authType === 'confirm-change-email') { const url = new URL(args.request.url); const searchParams = url.searchParams; - const token = searchParams.get('token'); + const token = searchParams.get('token') ?? ''; + const email = decodeURIComponent(searchParams.get('email') ?? ''); const res = await fetcher({ query: changeEmailMutation, variables: { - token: token || '', + token: token, + email: email, }, }).catch(console.error); // TODO: Add error handling if (!res?.changeEmail) { return redirect('/expired'); } + } else if (args.params.authType === 'verify-email') { + const url = new URL(args.request.url); + const searchParams = url.searchParams; + const token = searchParams.get('token') ?? ''; + const res = await fetcher({ + query: verifyEmailMutation, + variables: { + token: token, + }, + }).catch(console.error); + + if (!res?.verifyEmail) { + return redirect('/expired'); + } } return null; }; + export const Component = () => { const loginStatus = useCurrentLoginStatus(); const { jumpToExpired } = useNavigateHelper(); diff --git a/packages/frontend/core/src/pages/desktop-signin.tsx b/packages/frontend/core/src/pages/desktop-signin.tsx index f258fc16f2..04d45b3cb0 100644 --- a/packages/frontend/core/src/pages/desktop-signin.tsx +++ b/packages/frontend/core/src/pages/desktop-signin.tsx @@ -1,34 +1,43 @@ -import { getSession } from 'next-auth/react'; +import { OAuthProviderType } from '@affine/graphql'; import { type LoaderFunction } from 'react-router-dom'; import { z } from 'zod'; +import { getSession } from '../hooks/affine/use-current-user'; import { signInCloud, signOutCloud } from '../utils/cloud-utils'; -const supportedProvider = z.enum(['google']); +const supportedProvider = z.enum([ + 'google', + ...Object.values(OAuthProviderType), +]); export const loader: LoaderFunction = async ({ request }) => { const url = new URL(request.url); const searchParams = url.searchParams; const provider = searchParams.get('provider'); - const callback_url = searchParams.get('callback_url'); - if (!callback_url) { + const redirectUri = + searchParams.get('redirect_uri') ?? + /* backward compatibility */ searchParams.get('callback_url'); + + if (!redirectUri) { return null; } const session = await getSession(); - if (session) { + if (session.user) { // already signed in, need to sign out first - await signOutCloud({ - callbackUrl: request.url, // retry - }); + await signOutCloud(request.url); } const maybeProvider = supportedProvider.safeParse(provider); if (maybeProvider.success) { - const provider = maybeProvider.data; - await signInCloud(provider, { - callbackUrl: callback_url, + let provider = maybeProvider.data; + // BACKWARD COMPATIBILITY + if (provider === 'google') { + provider = OAuthProviderType.Google; + } + await signInCloud(provider, undefined, { + redirectUri, }); } return null; diff --git a/packages/frontend/core/src/pages/share/share-detail-page.tsx b/packages/frontend/core/src/pages/share/share-detail-page.tsx index 9e12f901c1..d9ff66a497 100644 --- a/packages/frontend/core/src/pages/share/share-detail-page.tsx +++ b/packages/frontend/core/src/pages/share/share-detail-page.tsx @@ -162,7 +162,7 @@ export const Component = () => { .then(() => { const { page } = workspace.services.get(PageManager).open(pageId); - workspace.blockSuiteWorkspace.awarenessStore.setReadonly( + workspace.docCollection.awarenessStore.setReadonly( page.blockSuiteDoc, true ); @@ -200,14 +200,14 @@ export const Component = () => { noop} /> diff --git a/packages/frontend/core/src/pages/share/share-header.tsx b/packages/frontend/core/src/pages/share/share-header.tsx index 3d25cd052e..1e6e8069b2 100644 --- a/packages/frontend/core/src/pages/share/share-header.tsx +++ b/packages/frontend/core/src/pages/share/share-header.tsx @@ -1,6 +1,6 @@ import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch'; import ShareHeaderRightItem from '@affine/core/components/cloud/share-header-right-item'; -import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import type { PageMode } from '@toeverything/infra'; import { BlocksuiteHeaderTitle } from '../../components/blocksuite/block-suite-header/title/index'; @@ -9,28 +9,28 @@ import * as styles from './share-header.css'; export function ShareHeader({ pageId, publishMode, - blockSuiteWorkspace, + docCollection, }: { pageId: string; publishMode: PageMode; - blockSuiteWorkspace: BlockSuiteWorkspace; + docCollection: DocCollection; }) { return (
diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx index edee066980..86b7b66f57 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page-filter.tsx @@ -40,7 +40,7 @@ export const FilterContainer = ({
diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx index 634ce35218..32220d6a0c 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page-header.tsx @@ -29,7 +29,7 @@ export const AllPageHeader = ({ } right={ diff --git a/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx b/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx index bac7c2a415..847a82eee0 100644 --- a/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx +++ b/packages/frontend/core/src/pages/workspace/all-page/all-page.tsx @@ -19,7 +19,7 @@ import { AllPageHeader } from './all-page-header'; export const AllPage = () => { const currentWorkspace = useService(Workspace); - const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace); + const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); const [hideHeaderCreateNew, setHideHeaderCreateNew] = useState(true); const [filters, setFilters] = useState([]); @@ -48,7 +48,7 @@ export const AllPage = () => { } - blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} + docCollection={currentWorkspace.docCollection} /> )}
@@ -65,10 +65,10 @@ export const Component = () => { useEffect(() => { function checkJumpOnce() { - for (const [pageId] of currentWorkspace.blockSuiteWorkspace.docs) { - const page = currentWorkspace.blockSuiteWorkspace.getDoc(pageId); + for (const [pageId] of currentWorkspace.docCollection.docs) { + const page = currentWorkspace.docCollection.getDoc(pageId); if (page && page.meta?.jumpOnce) { - currentWorkspace.blockSuiteWorkspace.meta.setDocMeta(page.id, { + currentWorkspace.docCollection.meta.setDocMeta(page.id, { jumpOnce: false, }); navigateHelper.jumpToPage(currentWorkspace.id, pageId); @@ -76,14 +76,9 @@ export const Component = () => { } } checkJumpOnce(); - return currentWorkspace.blockSuiteWorkspace.slots.docUpdated.on( - checkJumpOnce - ).dispose; - }, [ - currentWorkspace.blockSuiteWorkspace, - currentWorkspace.id, - navigateHelper, - ]); + return currentWorkspace.docCollection.slots.docUpdated.on(checkJumpOnce) + .dispose; + }, [currentWorkspace.docCollection, currentWorkspace.id, navigateHelper]); return ; }; diff --git a/packages/frontend/core/src/pages/workspace/all-tag/index.tsx b/packages/frontend/core/src/pages/workspace/all-tag/index.tsx index 4c65fe7fca..0f202086cb 100644 --- a/packages/frontend/core/src/pages/workspace/all-tag/index.tsx +++ b/packages/frontend/core/src/pages/workspace/all-tag/index.tsx @@ -3,23 +3,39 @@ import { TagListHeader, VirtualizedTagList, } from '@affine/core/components/page-list/tags'; +import { CreateOrEditTag } from '@affine/core/components/page-list/tags/create-tag'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; import { useService } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra'; +import { useCallback, useState } from 'react'; import { ViewBodyIsland, ViewHeaderIsland } from '../../../modules/workbench'; import { EmptyTagList } from '../page-list-empty'; import * as styles from './all-tag.css'; import { AllTagHeader } from './header'; +const EmptyTagListHeader = () => { + const [showCreateTagInput, setShowCreateTagInput] = useState(false); + const handleOpen = useCallback(() => { + setShowCreateTagInput(true); + }, [setShowCreateTagInput]); + + return ( +
+ + +
+ ); +}; + export const AllTag = () => { const currentWorkspace = useService(Workspace); - const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace); + const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); - const { tags, tagMetas, deleteTags } = useTagMetas( - currentWorkspace.blockSuiteWorkspace, - pageMetas - ); + const { tags, tagMetas, deleteTags } = useTagMetas(pageMetas); return ( <> @@ -35,7 +51,7 @@ export const AllTag = () => { onTagDelete={deleteTags} /> ) : ( - } /> + } /> )}
diff --git a/packages/frontend/core/src/pages/workspace/collection/index.tsx b/packages/frontend/core/src/pages/workspace/collection/index.tsx index 3d74cc28fd..7bcdc79c6a 100644 --- a/packages/frontend/core/src/pages/workspace/collection/index.tsx +++ b/packages/frontend/core/src/pages/workspace/collection/index.tsx @@ -98,7 +98,7 @@ export const Component = function CollectionPage() { navigate, params.collectionId, pushNotification, - workspace.blockSuiteWorkspace, + workspace.docCollection, workspace.id, ]); if (!collection) { diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx index 8225373065..3132ec94e2 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page-header.tsx @@ -46,16 +46,16 @@ export function JournalPageHeader({ page, workspace }: PageHeaderProps) { return (
- + {page ? ( @@ -74,13 +74,13 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) { return (
@@ -92,7 +92,7 @@ export function NormalPageHeader({ page, workspace }: PageHeaderProps) { export function DetailPageHeader(props: PageHeaderProps) { const { page } = props; - const { isJournal } = useJournalInfoHelper(page.workspace, page.id); + const { isJournal } = useJournalInfoHelper(page.collection, page.id); const isInTrash = page.meta?.trash; return isJournal && !isInTrash ? ( diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.css.ts b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.css.ts index e6b996897c..784bc72a03 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.css.ts +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.css.ts @@ -16,3 +16,19 @@ export const editorContainer = style({ flex: 1, zIndex: 0, }); +// brings styles of .affine-page-viewport from blocksuite +export const affineDocViewport = style({ + display: 'flex', + flexDirection: 'column', + userSelect: 'none', + containerName: 'viewport', + // todo: find out what this does in bs + containerType: 'inline-size', + background: cssVar('backgroundPrimaryColor'), + '@media': { + print: { + display: 'none', + zIndex: -1, + }, + }, +}); diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index 149f4c1078..020369f489 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -1,6 +1,7 @@ import { Scrollable } from '@affine/component'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; +import type { PageRootService } from '@blocksuite/blocks'; import { BookmarkService, customImageProxyMiddleware, @@ -22,6 +23,7 @@ import { } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra'; import { useService } from '@toeverything/infra'; +import clsx from 'clsx'; import { useSetAtom } from 'jotai'; import { memo, @@ -70,7 +72,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { const { openPage, jumpToTag } = useNavigateHelper(); const [editor, setEditor] = useState(null); const currentWorkspace = useService(Workspace); - const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; + const docCollection = currentWorkspace.docCollection; const isActiveView = useIsActiveView(); // TODO: remove jotai here @@ -86,7 +88,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { null ); - const pageMeta = useBlockSuiteDocMeta(blockSuiteWorkspace).find( + const pageMeta = useBlockSuiteDocMeta(docCollection).find( meta => meta.id === page.id ); @@ -135,7 +137,8 @@ const DetailPageImpl = memo(function DetailPageImpl() { EmbedLoomService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl); // provide page mode and updated date to blocksuite - const pageService = editorHost.std.spec.getService('affine:page'); + const pageService = + editorHost.std.spec.getService('affine:page'); const disposable = new DisposableGroup(); pageService.getEditorMode = (pageId: string) => @@ -152,12 +155,12 @@ const DetailPageImpl = memo(function DetailPageImpl() { page.setMode(mode); // fixme: it seems pageLinkClicked is not triggered sometimes? disposable.add( - editor.slots.docLinkClicked.on(({ docId }) => { - return openPage(blockSuiteWorkspace.id, docId); + pageService.slots.docLinkClicked.on(({ docId }) => { + return openPage(docCollection.id, docId); }) ); disposable.add( - editor.slots.tagClicked.on(({ tagId }) => { + pageService.slots.tagClicked.on(({ tagId }) => { jumpToTag(currentWorkspace.id, tagId); }) ); @@ -174,7 +177,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { }; }, [ - blockSuiteWorkspace.id, + docCollection.id, currentWorkspace.id, jumpToTag, mode, @@ -198,13 +201,19 @@ const DetailPageImpl = memo(function DetailPageImpl() {
{/* Add a key to force rerender when page changed, to avoid error boundary persisting. */} + - - + @@ -245,16 +254,14 @@ const DetailPageImpl = memo(function DetailPageImpl() { } /> - + ); }); export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { + const currentWorkspace = useService(Workspace); const pageRecordList = useService(PageRecordList); const pageListReady = useLiveData(pageRecordList.isReady); @@ -281,6 +288,11 @@ export const DetailPage = ({ pageId }: { pageId: string }): ReactElement => { }; }, [pageManager, pageRecord]); + // set sync engine priority target + useEffect(() => { + currentWorkspace.setPriorityRule(id => id.endsWith(pageId)); + }, [currentWorkspace, pageId]); + const jumpOnce = useLiveData(pageRecord?.meta.map(meta => meta.jumpOnce)); useEffect(() => { diff --git a/packages/frontend/core/src/pages/workspace/page-list-empty.tsx b/packages/frontend/core/src/pages/workspace/page-list-empty.tsx index 8dcb1a8b7e..d80fd861b4 100644 --- a/packages/frontend/core/src/pages/workspace/page-list-empty.tsx +++ b/packages/frontend/core/src/pages/workspace/page-list-empty.tsx @@ -1,7 +1,7 @@ import { Empty } from '@affine/component'; import { Trans } from '@affine/i18n'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { type ReactNode, useCallback } from 'react'; import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils'; @@ -9,14 +9,14 @@ import * as styles from './page-list-empty.css'; export const EmptyPageList = ({ type, - blockSuiteWorkspace, + docCollection, heading, }: { type: 'all' | 'trash' | 'shared' | 'public'; - blockSuiteWorkspace: Workspace; + docCollection: DocCollection; heading?: ReactNode; }) => { - const { createPage } = usePageHelper(blockSuiteWorkspace); + const { createPage } = usePageHelper(docCollection); const t = useAFFiNEI18N(); const onCreatePage = useCallback(() => { createPage?.(); diff --git a/packages/frontend/core/src/pages/workspace/tag/index.css.ts b/packages/frontend/core/src/pages/workspace/tag/index.css.ts new file mode 100644 index 0000000000..a5f5542709 --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/tag/index.css.ts @@ -0,0 +1,9 @@ +import { style } from '@vanilla-extract/css'; + +export const body = style({ + display: 'flex', + flexDirection: 'column', + flex: 1, + height: '100%', + width: '100%', +}); diff --git a/packages/frontend/core/src/pages/workspace/tag/index.tsx b/packages/frontend/core/src/pages/workspace/tag/index.tsx index 82e1eab740..de902a9e37 100644 --- a/packages/frontend/core/src/pages/workspace/tag/index.tsx +++ b/packages/frontend/core/src/pages/workspace/tag/index.tsx @@ -1,9 +1,13 @@ import { - PageListHeader, + TagPageListHeader, useTagMetas, VirtualizedPageList, } from '@affine/core/components/page-list'; import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta'; +import { + ViewBodyIsland, + ViewHeaderIsland, +} from '@affine/core/modules/workbench'; import { useService } from '@toeverything/infra'; import { Workspace } from '@toeverything/infra'; import { useMemo } from 'react'; @@ -12,15 +16,13 @@ import { useParams } from 'react-router-dom'; import { PageNotFound } from '../../404'; import { EmptyPageList } from '../page-list-empty'; import { TagDetailHeader } from './header'; +import * as styles from './index.css'; export const TagDetail = ({ tagId }: { tagId?: string }) => { const currentWorkspace = useService(Workspace); - const pageMetas = useBlockSuiteDocMeta(currentWorkspace.blockSuiteWorkspace); + const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection); - const { tags, filterPageMetaByTag } = useTagMetas( - currentWorkspace.blockSuiteWorkspace, - pageMetas - ); + const { tags, filterPageMetaByTag } = useTagMetas(pageMetas); const tagPageMetas = useMemo(() => { if (tagId) { return filterPageMetaByTag(tagId); @@ -39,16 +41,27 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => { return ( <> - - {tagPageMetas.length > 0 ? ( - - ) : ( - } - blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} - /> - )} + + + + +
+ {tagPageMetas.length > 0 ? ( + + ) : ( + + } + docCollection={currentWorkspace.docCollection} + /> + )} +
+
); }; diff --git a/packages/frontend/core/src/pages/workspace/trash-page.tsx b/packages/frontend/core/src/pages/workspace/trash-page.tsx index 36797baf76..651ad24d72 100644 --- a/packages/frontend/core/src/pages/workspace/trash-page.tsx +++ b/packages/frontend/core/src/pages/workspace/trash-page.tsx @@ -49,17 +49,17 @@ const TrashHeader = () => { export const TrashPage = () => { const currentWorkspace = useService(Workspace); - const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace; - assertExists(blockSuiteWorkspace); + const docCollection = currentWorkspace.docCollection; + assertExists(docCollection); - const pageMetas = useBlockSuiteDocMeta(blockSuiteWorkspace); + const pageMetas = useBlockSuiteDocMeta(docCollection); const filteredPageMetas = useFilteredPageMetas(currentWorkspace, pageMetas, { trash: true, }); const { restoreFromTrash, permanentlyDeletePage } = - useBlockSuiteMetaHelper(blockSuiteWorkspace); - const { isPreferredEdgeless } = usePageHelper(blockSuiteWorkspace); + useBlockSuiteMetaHelper(docCollection); + const { isPreferredEdgeless } = usePageHelper(docCollection); const t = useAFFiNEI18N(); const pageOperationsRenderer = useCallback( @@ -107,7 +107,7 @@ export const TrashPage = () => { rowAsLink groupBy={false} isPreferredEdgeless={isPreferredEdgeless} - blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace} + docCollection={currentWorkspace.docCollection} operationsRenderer={pageOperationsRenderer} itemRenderer={pageItemRenderer} headerRenderer={pageHeaderRenderer} @@ -115,7 +115,7 @@ export const TrashPage = () => { ) : ( )}
diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index a1922eae86..41ed373658 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -216,9 +216,7 @@ export const SignOutConfirmModal = () => { const onConfirm = useAsyncCallback(async () => { setOpen(false); - await signOutCloud({ - redirect: false, - }); + await signOutCloud(); // if current workspace is affine cloud, switch to local workspace if (currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD) { diff --git a/packages/frontend/core/src/providers/session-provider.tsx b/packages/frontend/core/src/providers/session-provider.tsx index 9decb80477..4993b40568 100644 --- a/packages/frontend/core/src/providers/session-provider.tsx +++ b/packages/frontend/core/src/providers/session-provider.tsx @@ -1,11 +1,10 @@ import { pushNotificationAtom } from '@affine/component/notification-center'; +import { useSession } from '@affine/core/hooks/affine/use-current-user'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { affine } from '@affine/electron-api'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace-impl'; -import { useAtom, useSetAtom } from 'jotai'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { SessionProvider, useSession } from 'next-auth/react'; +import { useSetAtom } from 'jotai'; import { type PropsWithChildren, startTransition, @@ -13,13 +12,11 @@ import { useRef, } from 'react'; -import { sessionAtom } from '../atoms/cloud-user'; import { useOnceSignedInEvents } from '../atoms/event'; -const SessionDefence = (props: PropsWithChildren) => { +export const CloudSessionProvider = (props: PropsWithChildren) => { const session = useSession(); const prevSession = useRef>(); - const [sessionInAtom, setSession] = useAtom(sessionAtom); const pushNotification = useSetAtom(pushNotificationAtom); const onceSignedInEvents = useOnceSignedInEvents(); const t = useAFFiNEI18N(); @@ -32,10 +29,6 @@ const SessionDefence = (props: PropsWithChildren) => { }, [onceSignedInEvents]); useEffect(() => { - if (sessionInAtom !== session && session.status === 'authenticated') { - setSession(session); - } - if (prevSession.current !== session && session.status !== 'loading') { // unauthenticated -> authenticated if ( @@ -55,22 +48,7 @@ const SessionDefence = (props: PropsWithChildren) => { } prevSession.current = session; } - }, [ - session, - sessionInAtom, - prevSession, - setSession, - pushNotification, - refreshAfterSignedInEvents, - t, - ]); + }, [session, prevSession, pushNotification, refreshAfterSignedInEvents, t]); + return props.children; }; - -export const CloudSessionProvider = ({ children }: PropsWithChildren) => { - return ( - - {children} - - ); -}; diff --git a/packages/frontend/core/src/shared/index.ts b/packages/frontend/core/src/shared/index.ts index 98f956e2ae..0f3592e555 100644 --- a/packages/frontend/core/src/shared/index.ts +++ b/packages/frontend/core/src/shared/index.ts @@ -1,7 +1,7 @@ import { DebugLogger } from '@affine/debug'; -import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; -export { BlockSuiteWorkspace }; +export { DocCollection }; export enum WorkspaceSubPath { ALL = 'all', diff --git a/packages/frontend/core/src/utils/cloud-utils.tsx b/packages/frontend/core/src/utils/cloud-utils.tsx index 4d27e54515..b70951a8d1 100644 --- a/packages/frontend/core/src/utils/cloud-utils.tsx +++ b/packages/frontend/core/src/utils/cloud-utils.tsx @@ -1,12 +1,12 @@ import { generateRandUTF16Chars, + getBaseUrl, + OAuthProviderType, SPAN_ID_BYTES, TRACE_ID_BYTES, traceReporter, } from '@affine/graphql'; import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from '@affine/workspace-impl'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { signIn, signOut } from 'next-auth/react'; type TraceParams = { startTime: string; @@ -43,62 +43,95 @@ function onRejectHandleTrace( return Promise.reject(res); } -export const signInCloud: typeof signIn = async (provider, ...rest) => { +type Providers = 'credentials' | 'email' | OAuthProviderType; + +export const signInCloud = async ( + provider: Providers, + credentials?: { email: string; password?: string }, + searchParams: Record = {} +): Promise => { const traceParams = genTraceParams(); - if (environment.isDesktop) { - if (provider === 'google') { + + if (provider === 'credentials' || provider === 'email') { + if (!credentials) { + throw new Error('Invalid Credentials'); + } + + return signIn(credentials, searchParams) + .then(res => onResolveHandleTrace(res, traceParams)) + .catch(err => onRejectHandleTrace(err, traceParams)); + } else if (OAuthProviderType[provider]) { + if (environment.isDesktop) { open( `${ runtimeConfig.serverUrlPrefix - }/desktop-signin?provider=google&callback_url=${buildCallbackUrl( + }/desktop-signin?provider=${provider}&redirect_uri=${buildRedirectUri( '/open-app/signin-redirect' )}`, '_target' ); - return; } else { - const [options, ...tail] = rest; - const callbackUrl = - runtimeConfig.serverUrlPrefix + - (provider === 'email' - ? '/open-app/signin-redirect' - : location.pathname); - return signIn( - provider, - { - ...options, - callbackUrl: buildCallbackUrl(callbackUrl), - }, - ...tail - ) - .then(res => onResolveHandleTrace(res, traceParams)) - .catch(err => onRejectHandleTrace(err, traceParams)); + location.href = `${ + runtimeConfig.serverUrlPrefix + }/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent( + searchParams.redirectUri ?? location.pathname + )}`; } + + return; } else { - return signIn(provider, ...rest) - .then(res => onResolveHandleTrace(res, traceParams)) - .catch(err => onRejectHandleTrace(err, traceParams)); + throw new Error('Invalid Provider'); } }; -export const signOutCloud: typeof signOut = async options => { +async function signIn( + credential: { email: string; password?: string }, + searchParams: Record = {} +) { + const url = new URL(getBaseUrl() + '/api/auth/sign-in'); + + for (const key in searchParams) { + url.searchParams.set(key, searchParams[key]); + } + + const redirectUri = + runtimeConfig.serverUrlPrefix + + (environment.isDesktop + ? buildRedirectUri('/open-app/signin-redirect') + : location.pathname); + + url.searchParams.set('redirect_uri', redirectUri); + + return fetch(url.toString(), { + method: 'POST', + body: JSON.stringify(credential), + headers: { + 'content-type': 'application/json', + }, + }); +} + +export const signOutCloud = async (redirectUri?: string) => { const traceParams = genTraceParams(); - return signOut({ - callbackUrl: '/', - ...options, - }) + return fetch(getBaseUrl() + '/api/auth/sign-out') .then(result => { - if (result) { + if (result.ok) { new BroadcastChannel( CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY ).postMessage(1); + + if (redirectUri && location.href !== redirectUri) { + setTimeout(() => { + location.href = redirectUri; + }, 0); + } } return onResolveHandleTrace(result, traceParams); }) .catch(err => onRejectHandleTrace(err, traceParams)); }; -export function buildCallbackUrl(callbackUrl: string) { +export function buildRedirectUri(callbackUrl: string) { const params: string[][] = []; if (environment.isDesktop && window.appInfo.schema) { params.push(['schema', window.appInfo.schema]); diff --git a/packages/frontend/core/src/utils/user-setting.ts b/packages/frontend/core/src/utils/user-setting.ts index 3a668b6459..cb1438bc46 100644 --- a/packages/frontend/core/src/utils/user-setting.ts +++ b/packages/frontend/core/src/utils/user-setting.ts @@ -1,16 +1,16 @@ import type { Collection } from '@affine/env/filter'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { nanoid } from 'nanoid'; import type { Map as YMap } from 'yjs'; import { Doc as YDoc } from 'yjs'; export class UserSetting { constructor( - private readonly workspace: Workspace, + private readonly docCollection: DocCollection, private readonly userId: string ) {} get setting(): YDoc { - const rootDoc = this.workspace.doc; + const rootDoc = this.docCollection.doc; const settingMap = rootDoc.getMap('settings') as YMap; if (!settingMap.has(this.userId)) { settingMap.set( @@ -38,6 +38,9 @@ export class UserSetting { } } -export const getUserSetting = (workspace: Workspace, userId: string) => { - return new UserSetting(workspace, userId); +export const getUserSetting = ( + docCollection: DocCollection, + userId: string +) => { + return new UserSetting(docCollection, userId); }; diff --git a/packages/frontend/electron/package.json b/packages/frontend/electron/package.json index 614fb45f0b..fa865cd917 100644 --- a/packages/frontend/electron/package.json +++ b/packages/frontend/electron/package.json @@ -25,10 +25,10 @@ "@affine-test/kit": "workspace:*", "@affine/env": "workspace:*", "@affine/native": "workspace:*", - "@blocksuite/blocks": "0.13.0-canary-202403050653-934469c", - "@blocksuite/lit": "0.13.0-canary-202403050653-934469c", - "@blocksuite/presets": "0.13.0-canary-202403050653-934469c", - "@blocksuite/store": "0.13.0-canary-202403050653-934469c", + "@blocksuite/blocks": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/lit": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/presets": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/store": "0.13.0-canary-202403140320-a2b362b", "@electron-forge/cli": "^7.3.0", "@electron-forge/core": "^7.3.0", "@electron-forge/core-utils": "^7.3.0", diff --git a/packages/frontend/electron/src/main/deep-link.ts b/packages/frontend/electron/src/main/deep-link.ts index a098bfb861..b61c9cd54f 100644 --- a/packages/frontend/electron/src/main/deep-link.ts +++ b/packages/frontend/electron/src/main/deep-link.ts @@ -8,7 +8,6 @@ import { logger } from './logger'; import { getMainWindow, handleOpenUrlInHiddenWindow, - removeCookie, setCookie, } from './main-window'; @@ -82,28 +81,16 @@ async function handleOauthJwt(url: string) { return; } - const isSecure = CLOUD_BASE_URL.startsWith('https://'); - // set token to cookie await setCookie({ url: CLOUD_BASE_URL, httpOnly: true, value: token, secure: true, - name: isSecure - ? '__Secure-next-auth.session-token' - : 'next-auth.session-token', + name: 'sid', expirationDate: Math.floor(Date.now() / 1000 + 3600 * 24 * 7), }); - // force reset next-auth.callback-url - // there could be incorrect callback-url in cookie that will cause auth failure - // so we need to reset it to empty to mitigate this issue - await removeCookie( - CLOUD_BASE_URL, - isSecure ? '__Secure-next-auth.callback-url' : 'next-auth.callback-url' - ); - let hiddenWindow: BrowserWindow | null = null; ipcMain.once('affine:login', () => { diff --git a/packages/frontend/electron/src/main/main-window.ts b/packages/frontend/electron/src/main/main-window.ts index 18df5a1b41..aaf5c182b6 100644 --- a/packages/frontend/electron/src/main/main-window.ts +++ b/packages/frontend/electron/src/main/main-window.ts @@ -54,6 +54,7 @@ async function createWindow(additionalArguments: string[]) { minHeight: 480, visualEffectState: 'active', vibrancy: 'under-window', + backgroundMaterial: 'mica', height: mainWindowState.height, show: false, // Use 'ready-to-show' event to show window webPreferences: { diff --git a/packages/frontend/electron/src/shared/utils.ts b/packages/frontend/electron/src/shared/utils.ts index 41e5bf65d7..51aa4f52ba 100644 --- a/packages/frontend/electron/src/shared/utils.ts +++ b/packages/frontend/electron/src/shared/utils.ts @@ -13,7 +13,7 @@ export const isWindows = () => { }; export const isLinux = () => { - return process.platform === 'win32'; + return process.platform === 'linux'; }; interface MessagePortLike { diff --git a/packages/frontend/graphql/src/graphql/change-email.gql b/packages/frontend/graphql/src/graphql/change-email.gql index 77746962ce..6ebd72e91f 100644 --- a/packages/frontend/graphql/src/graphql/change-email.gql +++ b/packages/frontend/graphql/src/graphql/change-email.gql @@ -1,8 +1,6 @@ -mutation changeEmail($token: String!) { - changeEmail(token: $token) { +mutation changeEmail($token: String!, $email: String!) { + changeEmail(token: $token, email: $email) { id - name - avatarUrl email } } diff --git a/packages/frontend/graphql/src/graphql/change-password.gql b/packages/frontend/graphql/src/graphql/change-password.gql index e1f86b3ffd..fb60b1a952 100644 --- a/packages/frontend/graphql/src/graphql/change-password.gql +++ b/packages/frontend/graphql/src/graphql/change-password.gql @@ -1,8 +1,5 @@ mutation changePassword($token: String!, $newPassword: String!) { changePassword(token: $token, newPassword: $newPassword) { id - name - avatarUrl - email } } diff --git a/packages/frontend/graphql/src/graphql/early-access-list.gql b/packages/frontend/graphql/src/graphql/early-access-list.gql index 13c92f22a6..1b87d67fa6 100644 --- a/packages/frontend/graphql/src/graphql/early-access-list.gql +++ b/packages/frontend/graphql/src/graphql/early-access-list.gql @@ -5,7 +5,6 @@ query earlyAccessUsers { email avatarUrl emailVerified - createdAt subscription { plan recurring diff --git a/packages/frontend/graphql/src/graphql/get-current-user.gql b/packages/frontend/graphql/src/graphql/get-current-user.gql index 272f527e04..8c9d837921 100644 --- a/packages/frontend/graphql/src/graphql/get-current-user.gql +++ b/packages/frontend/graphql/src/graphql/get-current-user.gql @@ -5,7 +5,6 @@ query getCurrentUser { email emailVerified avatarUrl - createdAt token { sessionToken } diff --git a/packages/frontend/graphql/src/graphql/get-oauth-providers.gql b/packages/frontend/graphql/src/graphql/get-oauth-providers.gql new file mode 100644 index 0000000000..afbdcc6a8e --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-oauth-providers.gql @@ -0,0 +1,5 @@ +query oauthProviders { + serverConfig { + oauthProviders + } +} diff --git a/packages/frontend/graphql/src/graphql/get-user-features.gql b/packages/frontend/graphql/src/graphql/get-user-features.gql new file mode 100644 index 0000000000..5c0cc29f78 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-user-features.gql @@ -0,0 +1,5 @@ +query getUserFeatures { + currentUser { + features + } +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 272ce02ab4..6c1acd4517 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -101,11 +101,9 @@ export const changeEmailMutation = { definitionName: 'changeEmail', containsFile: false, query: ` -mutation changeEmail($token: String!) { - changeEmail(token: $token) { +mutation changeEmail($token: String!, $email: String!) { + changeEmail(token: $token, email: $email) { id - name - avatarUrl email } }`, @@ -120,9 +118,6 @@ export const changePasswordMutation = { mutation changePassword($token: String!, $newPassword: String!) { changePassword(token: $token, newPassword: $newPassword) { id - name - avatarUrl - email } }`, }; @@ -212,7 +207,6 @@ query earlyAccessUsers { email avatarUrl emailVerified - createdAt subscription { plan recurring @@ -248,7 +242,6 @@ query getCurrentUser { email emailVerified avatarUrl - createdAt token { sessionToken } @@ -324,6 +317,19 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { }`, }; +export const oauthProvidersQuery = { + id: 'oauthProvidersQuery' as const, + operationName: 'oauthProviders', + definitionName: 'serverConfig', + containsFile: false, + query: ` +query oauthProviders { + serverConfig { + oauthProviders + } +}`, +}; + export const getPublicWorkspaceQuery = { id: 'getPublicWorkspaceQuery' as const, operationName: 'getPublicWorkspace', @@ -337,6 +343,19 @@ query getPublicWorkspace($id: String!) { }`, }; +export const getUserFeaturesQuery = { + id: 'getUserFeaturesQuery' as const, + operationName: 'getUserFeatures', + definitionName: 'currentUser', + containsFile: false, + query: ` +query getUserFeatures { + currentUser { + features + } +}`, +}; + export const getUserQuery = { id: 'getUserQuery' as const, operationName: 'getUser', @@ -627,8 +646,8 @@ export const sendChangeEmailMutation = { definitionName: 'sendChangeEmail', containsFile: false, query: ` -mutation sendChangeEmail($email: String!, $callbackUrl: String!) { - sendChangeEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendChangeEmail($callbackUrl: String!) { + sendChangeEmail(callbackUrl: $callbackUrl) }`, }; @@ -638,8 +657,8 @@ export const sendChangePasswordEmailMutation = { definitionName: 'sendChangePasswordEmail', containsFile: false, query: ` -mutation sendChangePasswordEmail($email: String!, $callbackUrl: String!) { - sendChangePasswordEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendChangePasswordEmail($callbackUrl: String!) { + sendChangePasswordEmail(callbackUrl: $callbackUrl) }`, }; @@ -649,8 +668,8 @@ export const sendSetPasswordEmailMutation = { definitionName: 'sendSetPasswordEmail', containsFile: false, query: ` -mutation sendSetPasswordEmail($email: String!, $callbackUrl: String!) { - sendSetPasswordEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendSetPasswordEmail($callbackUrl: String!) { + sendSetPasswordEmail(callbackUrl: $callbackUrl) }`, }; @@ -665,6 +684,17 @@ mutation sendVerifyChangeEmail($token: String!, $email: String!, $callbackUrl: S }`, }; +export const sendVerifyEmailMutation = { + id: 'sendVerifyEmailMutation' as const, + operationName: 'sendVerifyEmail', + definitionName: 'sendVerifyEmail', + containsFile: false, + query: ` +mutation sendVerifyEmail($callbackUrl: String!) { + sendVerifyEmail(callbackUrl: $callbackUrl) +}`, +}; + export const serverConfigQuery = { id: 'serverConfigQuery' as const, operationName: 'serverConfig', @@ -695,36 +725,6 @@ mutation setWorkspacePublicById($id: ID!, $public: Boolean!) { }`, }; -export const signInMutation = { - id: 'signInMutation' as const, - operationName: 'signIn', - definitionName: 'signIn', - containsFile: false, - query: ` -mutation signIn($email: String!, $password: String!) { - signIn(email: $email, password: $password) { - token { - token - } - } -}`, -}; - -export const signUpMutation = { - id: 'signUpMutation' as const, - operationName: 'signUp', - definitionName: 'signUp', - containsFile: false, - query: ` -mutation signUp($name: String!, $email: String!, $password: String!) { - signUp(name: $name, email: $email, password: $password) { - token { - token - } - } -}`, -}; - export const subscriptionQuery = { id: 'subscriptionQuery' as const, operationName: 'subscription', @@ -766,6 +766,20 @@ mutation updateSubscription($recurring: SubscriptionRecurring!, $idempotencyKey: }`, }; +export const updateUserProfileMutation = { + id: 'updateUserProfileMutation' as const, + operationName: 'updateUserProfile', + definitionName: 'updateProfile', + containsFile: false, + query: ` +mutation updateUserProfile($input: UpdateUserInput!) { + updateProfile(input: $input) { + id + name + } +}`, +}; + export const uploadAvatarMutation = { id: 'uploadAvatarMutation' as const, operationName: 'uploadAvatar', @@ -782,6 +796,17 @@ mutation uploadAvatar($avatar: Upload!) { }`, }; +export const verifyEmailMutation = { + id: 'verifyEmailMutation' as const, + operationName: 'verifyEmail', + definitionName: 'verifyEmail', + containsFile: false, + query: ` +mutation verifyEmail($token: String!) { + verifyEmail(token: $token) +}`, +}; + export const enabledFeaturesQuery = { id: 'enabledFeaturesQuery' as const, operationName: 'enabledFeatures', diff --git a/packages/frontend/graphql/src/graphql/send-change-email.gql b/packages/frontend/graphql/src/graphql/send-change-email.gql index b9421d15b5..0300a20427 100644 --- a/packages/frontend/graphql/src/graphql/send-change-email.gql +++ b/packages/frontend/graphql/src/graphql/send-change-email.gql @@ -1,3 +1,3 @@ -mutation sendChangeEmail($email: String!, $callbackUrl: String!) { - sendChangeEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendChangeEmail($callbackUrl: String!) { + sendChangeEmail(callbackUrl: $callbackUrl) } diff --git a/packages/frontend/graphql/src/graphql/send-change-password-email.gql b/packages/frontend/graphql/src/graphql/send-change-password-email.gql index ed99bab15b..3b40efba25 100644 --- a/packages/frontend/graphql/src/graphql/send-change-password-email.gql +++ b/packages/frontend/graphql/src/graphql/send-change-password-email.gql @@ -1,3 +1,3 @@ -mutation sendChangePasswordEmail($email: String!, $callbackUrl: String!) { - sendChangePasswordEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendChangePasswordEmail($callbackUrl: String!) { + sendChangePasswordEmail(callbackUrl: $callbackUrl) } diff --git a/packages/frontend/graphql/src/graphql/send-set-password-email.gql b/packages/frontend/graphql/src/graphql/send-set-password-email.gql index 8caaebd989..e8da4bbefb 100644 --- a/packages/frontend/graphql/src/graphql/send-set-password-email.gql +++ b/packages/frontend/graphql/src/graphql/send-set-password-email.gql @@ -1,3 +1,3 @@ -mutation sendSetPasswordEmail($email: String!, $callbackUrl: String!) { - sendSetPasswordEmail(email: $email, callbackUrl: $callbackUrl) +mutation sendSetPasswordEmail($callbackUrl: String!) { + sendSetPasswordEmail(callbackUrl: $callbackUrl) } diff --git a/packages/frontend/graphql/src/graphql/send-verify-email.gql b/packages/frontend/graphql/src/graphql/send-verify-email.gql new file mode 100644 index 0000000000..300f005916 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/send-verify-email.gql @@ -0,0 +1,3 @@ +mutation sendVerifyEmail($callbackUrl: String!) { + sendVerifyEmail(callbackUrl: $callbackUrl) +} diff --git a/packages/frontend/graphql/src/graphql/sign-in.gql b/packages/frontend/graphql/src/graphql/sign-in.gql deleted file mode 100644 index b43e7dcbee..0000000000 --- a/packages/frontend/graphql/src/graphql/sign-in.gql +++ /dev/null @@ -1,7 +0,0 @@ -mutation signIn($email: String!, $password: String!) { - signIn(email: $email, password: $password) { - token { - token - } - } -} diff --git a/packages/frontend/graphql/src/graphql/sign-up.gql b/packages/frontend/graphql/src/graphql/sign-up.gql deleted file mode 100644 index a93df61981..0000000000 --- a/packages/frontend/graphql/src/graphql/sign-up.gql +++ /dev/null @@ -1,7 +0,0 @@ -mutation signUp($name: String!, $email: String!, $password: String!) { - signUp(name: $name, email: $email, password: $password) { - token { - token - } - } -} diff --git a/packages/frontend/graphql/src/graphql/update-user-profile.gql b/packages/frontend/graphql/src/graphql/update-user-profile.gql new file mode 100644 index 0000000000..edb8d4fcb7 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/update-user-profile.gql @@ -0,0 +1,6 @@ +mutation updateUserProfile($input: UpdateUserInput!) { + updateProfile(input: $input) { + id + name + } +} diff --git a/packages/frontend/graphql/src/graphql/verify-email.gql b/packages/frontend/graphql/src/graphql/verify-email.gql new file mode 100644 index 0000000000..493a21e5b5 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/verify-email.gql @@ -0,0 +1,3 @@ +mutation verifyEmail($token: String!) { + verifyEmail(token: $token) +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index b92793f97f..2675c9ed6a 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -57,6 +57,11 @@ export enum InvoiceStatus { Void = 'Void', } +export enum OAuthProviderType { + GitHub = 'GitHub', + Google = 'Google', +} + /** User permission in workspace */ export enum Permission { Admin = 'Admin', @@ -77,6 +82,7 @@ export enum ServerDeploymentType { } export enum ServerFeature { + OAuth = 'OAuth', Payment = 'Payment', } @@ -104,6 +110,11 @@ export enum SubscriptionStatus { Unpaid = 'Unpaid', } +export interface UpdateUserInput { + /** User name */ + name: InputMaybe; +} + export interface UpdateWorkspaceInput { id: Scalars['ID']['input']; /** is Public workspace */ @@ -176,17 +187,12 @@ export type CancelSubscriptionMutation = { export type ChangeEmailMutationVariables = Exact<{ token: Scalars['String']['input']; + email: Scalars['String']['input']; }>; export type ChangeEmailMutation = { __typename?: 'Mutation'; - changeEmail: { - __typename?: 'UserType'; - id: string; - name: string; - avatarUrl: string | null; - email: string; - }; + changeEmail: { __typename?: 'UserType'; id: string; email: string }; }; export type ChangePasswordMutationVariables = Exact<{ @@ -196,13 +202,7 @@ export type ChangePasswordMutationVariables = Exact<{ export type ChangePasswordMutation = { __typename?: 'Mutation'; - changePassword: { - __typename?: 'UserType'; - id: string; - name: string; - avatarUrl: string | null; - email: string; - }; + changePassword: { __typename?: 'UserType'; id: string }; }; export type CreateCheckoutSessionMutationVariables = Exact<{ @@ -270,8 +270,7 @@ export type EarlyAccessUsersQuery = { name: string; email: string; avatarUrl: string | null; - emailVerified: string | null; - createdAt: string | null; + emailVerified: boolean; subscription: { __typename?: 'UserSubscription'; plan: SubscriptionPlan; @@ -301,10 +300,9 @@ export type GetCurrentUserQuery = { id: string; name: string; email: string; - emailVerified: string | null; + emailVerified: boolean; avatarUrl: string | null; - createdAt: string | null; - token: { __typename?: 'TokenType'; sessionToken: string | null }; + token: { __typename?: 'tokenType'; sessionToken: string | null }; } | null; }; @@ -365,11 +363,21 @@ export type GetMembersByWorkspaceIdQuery = { permission: Permission; inviteId: string; accepted: boolean; - emailVerified: string | null; + emailVerified: boolean | null; }>; }; }; +export type OauthProvidersQueryVariables = Exact<{ [key: string]: never }>; + +export type OauthProvidersQuery = { + __typename?: 'Query'; + serverConfig: { + __typename?: 'ServerConfigType'; + oauthProviders: Array; + }; +}; + export type GetPublicWorkspaceQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -379,6 +387,13 @@ export type GetPublicWorkspaceQuery = { publicWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; +export type GetUserFeaturesQueryVariables = Exact<{ [key: string]: never }>; + +export type GetUserFeaturesQuery = { + __typename?: 'Query'; + currentUser: { __typename?: 'UserType'; features: Array } | null; +}; + export type GetUserQueryVariables = Exact<{ email: Scalars['String']['input']; }>; @@ -628,7 +643,6 @@ export type RevokePublicPageMutation = { }; export type SendChangeEmailMutationVariables = Exact<{ - email: Scalars['String']['input']; callbackUrl: Scalars['String']['input']; }>; @@ -638,7 +652,6 @@ export type SendChangeEmailMutation = { }; export type SendChangePasswordEmailMutationVariables = Exact<{ - email: Scalars['String']['input']; callbackUrl: Scalars['String']['input']; }>; @@ -648,7 +661,6 @@ export type SendChangePasswordEmailMutation = { }; export type SendSetPasswordEmailMutationVariables = Exact<{ - email: Scalars['String']['input']; callbackUrl: Scalars['String']['input']; }>; @@ -668,6 +680,15 @@ export type SendVerifyChangeEmailMutation = { sendVerifyChangeEmail: boolean; }; +export type SendVerifyEmailMutationVariables = Exact<{ + callbackUrl: Scalars['String']['input']; +}>; + +export type SendVerifyEmailMutation = { + __typename?: 'Mutation'; + sendVerifyEmail: boolean; +}; + export type ServerConfigQueryVariables = Exact<{ [key: string]: never }>; export type ServerConfigQuery = { @@ -692,33 +713,6 @@ export type SetWorkspacePublicByIdMutation = { updateWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; -export type SignInMutationVariables = Exact<{ - email: Scalars['String']['input']; - password: Scalars['String']['input']; -}>; - -export type SignInMutation = { - __typename?: 'Mutation'; - signIn: { - __typename?: 'UserType'; - token: { __typename?: 'TokenType'; token: string }; - }; -}; - -export type SignUpMutationVariables = Exact<{ - name: Scalars['String']['input']; - email: Scalars['String']['input']; - password: Scalars['String']['input']; -}>; - -export type SignUpMutation = { - __typename?: 'Mutation'; - signUp: { - __typename?: 'UserType'; - token: { __typename?: 'TokenType'; token: string }; - }; -}; - export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>; export type SubscriptionQuery = { @@ -755,6 +749,15 @@ export type UpdateSubscriptionMutation = { }; }; +export type UpdateUserProfileMutationVariables = Exact<{ + input: UpdateUserInput; +}>; + +export type UpdateUserProfileMutation = { + __typename?: 'Mutation'; + updateProfile: { __typename?: 'UserType'; id: string; name: string }; +}; + export type UploadAvatarMutationVariables = Exact<{ avatar: Scalars['Upload']['input']; }>; @@ -770,6 +773,15 @@ export type UploadAvatarMutation = { }; }; +export type VerifyEmailMutationVariables = Exact<{ + token: Scalars['String']['input']; +}>; + +export type VerifyEmailMutation = { + __typename?: 'Mutation'; + verifyEmail: boolean; +}; + export type EnabledFeaturesQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -938,11 +950,21 @@ export type Queries = variables: GetMembersByWorkspaceIdQueryVariables; response: GetMembersByWorkspaceIdQuery; } + | { + name: 'oauthProvidersQuery'; + variables: OauthProvidersQueryVariables; + response: OauthProvidersQuery; + } | { name: 'getPublicWorkspaceQuery'; variables: GetPublicWorkspaceQueryVariables; response: GetPublicWorkspaceQuery; } + | { + name: 'getUserFeaturesQuery'; + variables: GetUserFeaturesQueryVariables; + response: GetUserFeaturesQuery; + } | { name: 'getUserQuery'; variables: GetUserQueryVariables; @@ -1145,31 +1167,36 @@ export type Mutations = variables: SendVerifyChangeEmailMutationVariables; response: SendVerifyChangeEmailMutation; } + | { + name: 'sendVerifyEmailMutation'; + variables: SendVerifyEmailMutationVariables; + response: SendVerifyEmailMutation; + } | { name: 'setWorkspacePublicByIdMutation'; variables: SetWorkspacePublicByIdMutationVariables; response: SetWorkspacePublicByIdMutation; } - | { - name: 'signInMutation'; - variables: SignInMutationVariables; - response: SignInMutation; - } - | { - name: 'signUpMutation'; - variables: SignUpMutationVariables; - response: SignUpMutation; - } | { name: 'updateSubscriptionMutation'; variables: UpdateSubscriptionMutationVariables; response: UpdateSubscriptionMutation; } + | { + name: 'updateUserProfileMutation'; + variables: UpdateUserProfileMutationVariables; + response: UpdateUserProfileMutation; + } | { name: 'uploadAvatarMutation'; variables: UploadAvatarMutationVariables; response: UploadAvatarMutation; } + | { + name: 'verifyEmailMutation'; + variables: VerifyEmailMutationVariables; + response: VerifyEmailMutation; + } | { name: 'setWorkspaceExperimentalFeatureMutation'; variables: SetWorkspaceExperimentalFeatureMutationVariables; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index a2b60c2fb6..8a818aa5dc 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -406,10 +406,12 @@ "com.affine.appearanceSettings.windowFrame.description": "Customise appearance of Windows Client.", "com.affine.appearanceSettings.windowFrame.frameless": "Frameless", "com.affine.appearanceSettings.windowFrame.title": "Window frame style", - "com.affine.auth.change.email.message": "Your current email is {{email}}. We’ll send a temporary verification link to this email.", + "com.affine.auth.verify.email.message": "Your current email is {{email}}. We’ll send a temporary verification link to this email.", "com.affine.auth.change.email.page.subtitle": "Please enter your new email address below. We will send a verification link to this email address to complete the process.", "com.affine.auth.change.email.page.success.subtitle": "Congratulations! You have successfully updated the email address associated with your AFFiNE Cloud account.", "com.affine.auth.change.email.page.success.title": "Email address updated!", + "com.affine.auth.verify.email.page.success.title": "Email address verified!", + "com.affine.auth.verify.email.page.success.subtitle": "Congratulations! You have successfully verified the email address associated with your AFFiNE Cloud account.", "com.affine.auth.change.email.page.title": "Change email address", "com.affine.auth.create.count": "Create Account", "com.affine.auth.desktop.signing.in": "Signing in...", @@ -430,11 +432,11 @@ "com.affine.auth.reset.password.message": "You will receive an email with a link to reset your password. Please check your inbox.", "com.affine.auth.reset.password.page.success": "Password reset successful", "com.affine.auth.reset.password.page.title": "Reset your AFFiNE Cloud password", - "com.affine.auth.send.change.email.link": "Send verification link", + "com.affine.auth.send.verify.email.hint": "Send verification link", "com.affine.auth.send.reset.password.link": "Send reset link", "com.affine.auth.send.set.password.link": "Send set link", "com.affine.auth.sent": "Sent", - "com.affine.auth.sent.change.email.hint": "Verification link has been sent.", + "com.affine.auth.sent.verify.email.hint": "Verification link has been sent.", "com.affine.auth.sent.change.email.fail": "The verification link failed to be sent, please try again later.", "com.affine.auth.sent.change.password.hint": "Reset password link has been sent.", "com.affine.auth.sent.reset.password.success.message": "Your password has upgraded! You can sign in AFFiNE Cloud with new password!", @@ -568,7 +570,7 @@ "com.affine.collection.removePage.success": "Removed successfully", "com.affine.collection.toolbar.selected": "<0>{{count}} selected", "com.affine.collection.toolbar.selected_one": "<0>{{count}} collection selected", - "com.affine.collection.toolbar.selected_others": "<0>{{count}} collection(s) selected", + "com.affine.collection.toolbar.selected_other": "<0>{{count}} collection(s) selected", "com.affine.collectionBar.backToAll": "Back to all", "com.affine.collections.empty.message": "No collections", "com.affine.collections.empty.new-collection-button": "New Collection", @@ -804,7 +806,7 @@ "com.affine.page.group-header.select-all": "Select All", "com.affine.page.toolbar.selected": "<0>{{count}} selected", "com.affine.page.toolbar.selected_one": "<0>{{count}} doc selected", - "com.affine.page.toolbar.selected_others": "<0>{{count}} doc(s) selected", + "com.affine.page.toolbar.selected_other": "<0>{{count}} doc(s) selected", "com.affine.pageMode": "Doc Mode", "com.affine.pageMode.all": "all", "com.affine.pageMode.edgeless": "Edgeless", @@ -951,7 +953,8 @@ "com.affine.settings.auto-check-description": "If enabled, it will automatically check for new versions at regular intervals.", "com.affine.settings.auto-download-description": " If enabled, new versions will be automatically downloaded to the current device.", "com.affine.settings.email": "Email", - "com.affine.settings.email.action": "Change Email", + "com.affine.settings.email.action.change": "Change Email", + "com.affine.settings.email.action.verify": "Verify Email", "com.affine.settings.member-tooltip": "Enable AFFiNE Cloud to collaborate with others", "com.affine.settings.noise-style": "Noise background on the sidebar", "com.affine.settings.noise-style-description": "Use background noise effect on the sidebar.", @@ -1051,7 +1054,7 @@ "com.affine.storage.used.hint": "Space used", "com.affine.tag.toolbar.selected": "<0>{{count}} selected", "com.affine.tag.toolbar.selected_one": "<0>{{count}} tag selected", - "com.affine.tag.toolbar.selected_others": "<0>{{count}} tag(s) selected", + "com.affine.tag.toolbar.selected_other": "<0>{{count}} tag(s) selected", "com.affine.themeSettings.dark": "Dark", "com.affine.themeSettings.light": "Light", "com.affine.themeSettings.system": "System", @@ -1146,5 +1149,21 @@ "com.affine.workbench.split-view-menu.close": "Close", "com.affine.workbench.split-view-menu.move-left": "Move Left", "com.affine.workbench.split-view-menu.move-right": "Move Right", - "com.affine.workbench.split-view-menu.full-screen": "Full Screen" + "com.affine.workbench.split-view-menu.full-screen": "Full Screen", + "com.affine.tags.empty.new-tag-button": "New Tag", + "com.affine.tags.create-tag.placeholder": "Type tag name here...", + "com.affine.tags.create-tag.toast.success": "Tag created", + "com.affine.tags.create-tag.toast.exist": "Tag already exists", + "com.affine.tags.edit-tag.toast.success": "Tag updated", + "com.affine.tags.count": "{{count}} doc", + "com.affine.tags.count_zero": "{{count}} doc", + "com.affine.tags.count_one": "{{count}} doc", + "com.affine.tags.count_other": "{{count}} docs", + "com.affine.tags.delete-tags.toast": "Tag deleted", + "com.affine.delete-tags.count": "{{count}} tag deleted", + "com.affine.delete-tags.count_one": "{{count}} tag deleted", + "com.affine.delete-tags.count_other": "{{count}} tags deleted", + "com.affine.workbench.split-view-menu.keep-this-one": "Keep this one", + "com.affine.workbench.split-view.page-menu-open": "Split View", + "com.affine.search-tags.placeholder": "Type here ..." } diff --git a/packages/frontend/i18n/src/resources/fr.json b/packages/frontend/i18n/src/resources/fr.json index 9079833774..468fd8bea1 100644 --- a/packages/frontend/i18n/src/resources/fr.json +++ b/packages/frontend/i18n/src/resources/fr.json @@ -406,7 +406,7 @@ "com.affine.appearanceSettings.windowFrame.description": "Personnalisez l'apparence de l'application Windows", "com.affine.appearanceSettings.windowFrame.frameless": "Sans Bords", "com.affine.appearanceSettings.windowFrame.title": "Style de fenêtre", - "com.affine.auth.change.email.message": "Votre email actuel est {{email}}. Nous enverrons un lien de vérification temporaire à cette addresse.", + "com.affine.auth.verify.email.message": "Votre email actuel est {{email}}. Nous enverrons un lien de vérification temporaire à cette addresse.", "com.affine.auth.change.email.page.subtitle": "Rentrez votre nouvelle adresse mail en dessous. Nous enverrons un lien de vérification à cette adresse mail pour compléter le processus", "com.affine.auth.change.email.page.success.subtitle": "Félicitation ! Vous avez réussi à mettre à jour votre adresse mail associé avec votre compte AFFiNE cloud ", "com.affine.auth.change.email.page.success.title": "Adresse mail mise à jour !", @@ -430,11 +430,11 @@ "com.affine.auth.reset.password.message": "Vous allez recevoir un mail avec un lien pour réinitialiser votre mot de passe. Merci de vérifier votre boite de réception", "com.affine.auth.reset.password.page.success": "Mot de passe réinitialisé avec succès", "com.affine.auth.reset.password.page.title": "Réinitialiser votre mot de passe AFFiNE Cloud", - "com.affine.auth.send.change.email.link": "Envoyer un lien de vérification", + "com.affine.auth.send.verify.email.hint": "Envoyer un lien de vérification", "com.affine.auth.send.reset.password.link": "Envoyer un lien de réinitialisation", "com.affine.auth.send.set.password.link": "Envoyer un lien pour définir votre mot de passe", "com.affine.auth.sent": "Envoyé", - "com.affine.auth.sent.change.email.hint": "Le lien de vérification a été envoyé", + "com.affine.auth.sent.verify.email.hint": "Le lien de vérification a été envoyé", "com.affine.auth.sent.change.password.hint": "Le lien de réinitialisation de mot de passe a été envoyé", "com.affine.auth.sent.reset.password.success.message": "Votre mot de passe a été changé ! Vous pouvez à nouveau vous connecter à AFFiNE Cloud avec votre nouveau mot de passe ! ", "com.affine.auth.sent.set.password.hint": "Le lien pour définir votre mot de passe à été envoyé", @@ -788,7 +788,7 @@ "com.affine.settings.auto-check-description": "Si activé, l'option cherchera automatiquement pour les nouvelles versions à intervalles réguliers", "com.affine.settings.auto-download-description": "Si activé, les nouvelles versions seront automatiquement téléchargées sur l'appareil actuel", "com.affine.settings.email": "Email", - "com.affine.settings.email.action": "Changer l'Email", + "com.affine.settings.email.action.change": "Changer l'Email", "com.affine.settings.member-tooltip": "Activer AFFiNE Cloud pour collaborer avec d'autres personnes", "com.affine.settings.noise-style": "Bruit d'arrière-plan de la barre latérale", "com.affine.settings.noise-style-description": "Utiliser l'effet de bruit d'arrière-plan sur la barre latérale", diff --git a/packages/frontend/i18n/src/resources/ko.json b/packages/frontend/i18n/src/resources/ko.json index 4cd2805a12..02bb7cddb9 100644 --- a/packages/frontend/i18n/src/resources/ko.json +++ b/packages/frontend/i18n/src/resources/ko.json @@ -406,7 +406,7 @@ "com.affine.appearanceSettings.windowFrame.description": "Windows 클라이언트의 모양을 사용자 정의합니다.", "com.affine.appearanceSettings.windowFrame.frameless": "프레임 없이", "com.affine.appearanceSettings.windowFrame.title": "윈도우 프레임 스타일", - "com.affine.auth.change.email.message": "현재 이메일은 {{email}}입니다. 이 이메일 주소로 임시 인증 링크를 보내 드리겠습니다.", + "com.affine.auth.verify.email.message": "현재 이메일은 {{email}}입니다. 이 이메일 주소로 임시 인증 링크를 보내 드리겠습니다.", "com.affine.auth.change.email.page.subtitle": "아래에 새 이메일 주소를 입력하세요. 절차를 완료하기 위해 이 이메일 주소로 인증 링크를 보내드립니다.", "com.affine.auth.change.email.page.success.subtitle": "축하합니다! AFFiNE Cloud 계정과 연결된 이메일 주소를 성공적으로 업데이트했습니다.", "com.affine.auth.change.email.page.success.title": "이메일 주소를 업데이트했습니다!", @@ -430,11 +430,11 @@ "com.affine.auth.reset.password.message": "비밀번호를 재설정할 수 있는 링크가 포함된 이메일을 받게 됩니다. 받은 편지함을 확인해 주세요.", "com.affine.auth.reset.password.page.success": "비밀번호 재설정 성공", "com.affine.auth.reset.password.page.title": "AFFiNE Cloud 비밀번호 재설정", - "com.affine.auth.send.change.email.link": "인증 링크 전송", + "com.affine.auth.send.verify.email.hint": "인증 링크 전송", "com.affine.auth.send.reset.password.link": "재설정 링크 전송", "com.affine.auth.send.set.password.link": "설정 링크 전송", "com.affine.auth.sent": "보냄", - "com.affine.auth.sent.change.email.hint": "인증 링크를 보냈습니다.", + "com.affine.auth.sent.verify.email.hint": "인증 링크를 보냈습니다.", "com.affine.auth.sent.change.password.hint": "비밀번호 재설정 링크를 보냈습니다.", "com.affine.auth.sent.reset.password.success.message": "비밀번호가 업그레이드했습니다! 새 비밀번호로 AFFiNE Cloud에 로그인할 수 있습니다!", "com.affine.auth.sent.set.password.hint": "비밀번호 설정 링크를 보냈습니다.", @@ -908,7 +908,7 @@ "com.affine.settings.auto-check-description": "이 기능을 활성화하면, 정기적으로 새 버전을 자동으로 확인합니다.", "com.affine.settings.auto-download-description": "이 기능을 활성화하면, 새 버전이 현재 디바이스에 자동으로 다운로드됩니다.", "com.affine.settings.email": "이메일", - "com.affine.settings.email.action": "이메일 변경", + "com.affine.settings.email.action.change": "이메일 변경", "com.affine.settings.member-tooltip": "다른 사람들과 협업할 수 있는 AFFiNE Cloud 활성화", "com.affine.settings.noise-style": "Noise background on the sidebar", "com.affine.settings.noise-style-description": "Use background noise effect on the sidebar.", diff --git a/packages/frontend/i18n/src/resources/pt-BR.json b/packages/frontend/i18n/src/resources/pt-BR.json index ce6e358b6c..8c41041054 100644 --- a/packages/frontend/i18n/src/resources/pt-BR.json +++ b/packages/frontend/i18n/src/resources/pt-BR.json @@ -337,11 +337,11 @@ "com.affine.auth.reset.password": "Redefinir Senha", "com.affine.auth.reset.password.message": "Você receberá um email com um link para redefinir sua senha. Por favor verifique sua caixa de entrada.", "com.affine.auth.reset.password.page.title": "Redefina sua senha da AFFiNE Cloud", - "com.affine.auth.send.change.email.link": "Envie um link de verificação", + "com.affine.auth.send.verify.email.hint": "Envie um link de verificação", "com.affine.auth.send.reset.password.link": "Enviar link de redefinição", "com.affine.auth.send.set.password.link": "Enviar link de definição", "com.affine.auth.sent": "Enviado", - "com.affine.auth.sent.change.email.hint": "Link de verificação foi enviado.", + "com.affine.auth.sent.verify.email.hint": "Link de verificação foi enviado.", "com.affine.auth.sent.change.password.hint": "Link de redefinição de senha foi enviado.", "com.affine.auth.set.email.save": "Salvar Email", "com.affine.auth.set.password.page.title": "Defina sua senha para AFFiNE Cloud", @@ -422,7 +422,7 @@ "com.affine.settings.auto-check-description": "Se ativado, ele verificará automaticamente novas versões em intervalos regulares.", "com.affine.settings.auto-download-description": "Se ativado, novas versões serão baixadas automaticamente para o dispositivo atual.", "com.affine.settings.email": "Email", - "com.affine.settings.email.action": "Mudar Email", + "com.affine.settings.email.action.change": "Mudar Email", "com.affine.settings.password": "Senha", "com.affine.settings.password.action.change": "Mudar senha", "com.affine.settings.profile": "Meu Perfil", diff --git a/packages/frontend/i18n/src/resources/ru.json b/packages/frontend/i18n/src/resources/ru.json index 167332b6cc..1776d7e444 100644 --- a/packages/frontend/i18n/src/resources/ru.json +++ b/packages/frontend/i18n/src/resources/ru.json @@ -318,10 +318,10 @@ "com.affine.auth.reset.password": "Восстановить пароль", "com.affine.auth.reset.password.message": "Вы получите письмо со ссылкой для восстановления пароля. Пожалуйста, проверьте свой почтовый ящик.", "com.affine.auth.reset.password.page.title": "Восстановить пароль AFFiNE Cloud", - "com.affine.auth.send.change.email.link": "Отправить ссылку для подтверждения", + "com.affine.auth.send.verify.email.hint": "Отправить ссылку для подтверждения", "com.affine.auth.send.reset.password.link": "Отправить ссылку для восстановления", "com.affine.auth.sent": "Отправлено", - "com.affine.auth.sent.change.email.hint": "Ссылка для подтверждения отправлена.", + "com.affine.auth.sent.verify.email.hint": "Ссылка для подтверждения отправлена.", "com.affine.auth.sent.change.password.hint": "Ссылка для восстановления пароля отправлена.", "com.affine.auth.sent.set.password.hint": "Ссылка для установки пароля отправлена.", "com.affine.auth.set.email.save": "Сохранить электронную почту", diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index 212f712dfd..b2e1f21a68 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -394,7 +394,7 @@ "com.affine.appearanceSettings.windowFrame.description": "自定义 Windows 客户端外观。", "com.affine.appearanceSettings.windowFrame.frameless": "无边框", "com.affine.appearanceSettings.windowFrame.title": "视窗样式", - "com.affine.auth.change.email.message": "您当前的邮箱是 {{email}}。我们将向此邮箱发送一个临时的验证链接。", + "com.affine.auth.verify.email.message": "您当前的邮箱是 {{email}}。我们将向此邮箱发送一个临时的验证链接。", "com.affine.auth.change.email.page.subtitle": "请在下方输入您的新电子邮件地址。我们将把验证链接发送至该电子邮件地址以完成此过程。", "com.affine.auth.change.email.page.success.subtitle": "恭喜!您已更新了与 AFFiNE Cloud 账户关联的电子邮件地址。", "com.affine.auth.change.email.page.success.title": "邮箱地址已更新!", @@ -418,11 +418,11 @@ "com.affine.auth.reset.password.message": "您将收到一封电子邮件,以便重置密码。请在收件箱中查收。", "com.affine.auth.reset.password.page.success": "密码重置成功", "com.affine.auth.reset.password.page.title": "重置您的 AFFiNE Cloud 密码", - "com.affine.auth.send.change.email.link": "发送验证链接", + "com.affine.auth.send.verify.email.hint": "发送验证链接", "com.affine.auth.send.reset.password.link": "发送重置链接", "com.affine.auth.send.set.password.link": "发送设置链接", "com.affine.auth.sent": "已发送", - "com.affine.auth.sent.change.email.hint": "验证链接已发送", + "com.affine.auth.sent.verify.email.hint": "验证链接已发送", "com.affine.auth.sent.change.password.hint": "重置密码链接已发送。", "com.affine.auth.sent.reset.password.success.message": "您的密码已更新!您可以使用新密码登录 AFFiNE Cloud!", "com.affine.auth.sent.set.password.hint": "设置密码链接已发送。", @@ -821,7 +821,8 @@ "com.affine.settings.auto-check-description": "如果启用,它将定期自动检查新版本。", "com.affine.settings.auto-download-description": "如果启用,新版本将自动下载到当前设备。", "com.affine.settings.email": "电子邮件", - "com.affine.settings.email.action": "更改邮箱", + "com.affine.settings.email.action.change": "更改邮箱", + "com.affine.settings.email.action.verify": "验证邮箱", "com.affine.settings.member-tooltip": "启用 AFFiNE Cloud 以与他人协作", "com.affine.settings.noise-style": "侧边栏的噪点背景", "com.affine.settings.noise-style-description": "在侧边栏使用噪点背景效果。", diff --git a/packages/frontend/i18n/src/resources/zh-Hant.json b/packages/frontend/i18n/src/resources/zh-Hant.json index 4e7a941f8c..45e570445a 100644 --- a/packages/frontend/i18n/src/resources/zh-Hant.json +++ b/packages/frontend/i18n/src/resources/zh-Hant.json @@ -336,11 +336,11 @@ "com.affine.auth.reset.password": "重設密碼", "com.affine.auth.reset.password.message": "您將收到一封電子郵件,其中包含重設密碼的連結。請檢查您的收件箱。", "com.affine.auth.reset.password.page.title": "重設您的 AFFiNE Cloud 密碼", - "com.affine.auth.send.change.email.link": "發送驗證連結", + "com.affine.auth.send.verify.email.hint": "發送驗證連結", "com.affine.auth.send.reset.password.link": "發送重設連結", "com.affine.auth.send.set.password.link": "發送設定連結", "com.affine.auth.sent": "已發送", - "com.affine.auth.sent.change.email.hint": "驗證連結已發送。", + "com.affine.auth.sent.verify.email.hint": "驗證連結已發送。", "com.affine.auth.sent.change.password.hint": "重設密碼連結已發送。", "com.affine.auth.sent.set.password.hint": "設定密碼連結已發送。", "com.affine.auth.set.email.save": "保存電子郵件地址", @@ -438,7 +438,8 @@ "com.affine.settings.auto-check-description": "若啟用,將定期自動檢測新版本。", "com.affine.settings.auto-download-description": "若啟用,將自動下載新版本。", "com.affine.settings.email": "電子郵件地址", - "com.affine.settings.email.action": "更改電子郵件地址", + "com.affine.settings.email.action.change": "更改電子郵件地址", + "com.affine.settings.email.action.verify": "验证電子郵件地址", "com.affine.settings.member-tooltip": "啟用 AFFiNE Cloud 以與他人協作", "com.affine.settings.noise-style": "側欄背景雜訊效果", "com.affine.settings.noise-style-description": "在側欄背景使用雜訊效果。", diff --git a/packages/frontend/native/index.js b/packages/frontend/native/index.js index 33cc759db6..2aae7d108d 100644 --- a/packages/frontend/native/index.js +++ b/packages/frontend/native/index.js @@ -88,7 +88,7 @@ switch (platform) { } break default: - throw new Error(`Unsupported architecture on Android ${arch}`) + loadError = new Error(`Unsupported architecture on Android ${arch}`) } break case 'win32': @@ -136,7 +136,7 @@ switch (platform) { } break default: - throw new Error(`Unsupported architecture on Windows: ${arch}`) + loadError = new Error(`Unsupported architecture on Windows: ${arch}`) } break case 'darwin': @@ -177,22 +177,37 @@ switch (platform) { } break default: - throw new Error(`Unsupported architecture on macOS: ${arch}`) + loadError = new Error(`Unsupported architecture on macOS: ${arch}`) } break case 'freebsd': - if (arch !== 'x64') { - throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) - } - localFileExisted = existsSync(join(__dirname, 'affine.freebsd-x64.node')) - try { - if (localFileExisted) { - nativeBinding = require('./affine.freebsd-x64.node') - } else { - nativeBinding = require('@affine/native-freebsd-x64') - } - } catch (e) { - loadError = e + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'affine.freebsd-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./affine.freebsd-x64.node') + } else { + nativeBinding = require('@affine/native-freebsd-x64') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'affine.freebsd-arm64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./affine.freebsd-arm64.node') + } else { + nativeBinding = require('@affine/native-freebsd-arm64') + } + } catch (e) { + loadError = e + } + break + default: + loadError = new Error(`Unsupported architecture on FreeBSD: ${arch}`) } break case 'linux': @@ -298,25 +313,43 @@ switch (platform) { } } break + case 's390x': + localFileExisted = existsSync( + join(__dirname, 'affine.linux-s390x-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./affine.linux-s390x-gnu.node') + } else { + nativeBinding = require('@affine/native-linux-s390x-gnu') + } + } catch (e) { + loadError = e + } + break default: - throw new Error(`Unsupported architecture on Linux: ${arch}`) + loadError = new Error(`Unsupported architecture on Linux: ${arch}`) } break default: - throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) + loadError = new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) } if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) { try { nativeBinding = require('./affine.wasi.cjs') - } catch { - // ignore + } catch (err) { + if (process.env.NAPI_RS_FORCE_WASI) { + console.error(err) + } } if (!nativeBinding) { try { nativeBinding = require('@affine/native-wasm32-wasi') } catch (err) { - console.error(err) + if (process.env.NAPI_RS_FORCE_WASI) { + console.error(err) + } } } } diff --git a/packages/frontend/native/src/hashcash.rs b/packages/frontend/native/src/hashcash.rs index 9ada59950c..4ef6054e25 100644 --- a/packages/frontend/native/src/hashcash.rs +++ b/packages/frontend/native/src/hashcash.rs @@ -45,7 +45,7 @@ impl Stamp { // check challenge let mut hasher = Sha3_256::new(); - hasher.update(&self.format().as_bytes()); + hasher.update(self.format().as_bytes()); let result = format!("{:x}", hasher.finalize()); result[..hex_digits] == String::from_utf8(vec![b'0'; hex_digits]).unwrap() } else { @@ -87,7 +87,7 @@ impl Stamp { let hex_digits = ((bits as f32) / 4.).ceil() as usize; let zeros = String::from_utf8(vec![b'0'; hex_digits]).unwrap(); loop { - hasher.update(&format!("{}:{:x}", challenge, counter).as_bytes()); + hasher.update(format!("{}:{:x}", challenge, counter).as_bytes()); let result = format!("{:x}", hasher.finalize_reset()); if result[..hex_digits] == zeros { break format!("{:x}", counter); 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 diff --git a/packages/frontend/workspace-impl/package.json b/packages/frontend/workspace-impl/package.json index f735db316b..4f6db36f6c 100644 --- a/packages/frontend/workspace-impl/package.json +++ b/packages/frontend/workspace-impl/package.json @@ -21,7 +21,6 @@ "is-svg": "^5.0.0", "lodash-es": "^4.17.21", "nanoid": "^5.0.6", - "next-auth": "^4.24.5", "socket.io-client": "^4.7.4", "y-protocols": "^1.0.6", "yjs": "^13.6.12" diff --git a/packages/frontend/workspace-impl/src/cloud/list.ts b/packages/frontend/workspace-impl/src/cloud/list.ts index 38965fa2b3..e0713b5893 100644 --- a/packages/frontend/workspace-impl/src/cloud/list.ts +++ b/packages/frontend/workspace-impl/src/cloud/list.ts @@ -2,10 +2,11 @@ import { WorkspaceFlavour } from '@affine/env/workspace'; import { createWorkspaceMutation, deleteWorkspaceMutation, + findGraphQLError, getWorkspacesQuery, } from '@affine/graphql'; import { fetcher } from '@affine/graphql'; -import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import type { WorkspaceListProvider } from '@toeverything/infra'; import { type BlobStorage, @@ -16,7 +17,6 @@ import { import { globalBlockSuiteSchema } from '@toeverything/infra'; import { difference } from 'lodash-es'; import { nanoid } from 'nanoid'; -import { getSession } from 'next-auth/react'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { IndexedDBBlobStorage } from '../local/blob-indexeddb'; @@ -27,13 +27,11 @@ import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts'; import { AffineStaticSyncStorage } from './sync'; async function getCloudWorkspaceList() { - const session = await getSession(); - if (!session) { - return []; - } try { const { workspaces } = await fetcher({ query: getWorkspacesQuery, + }).catch(() => { + return { workspaces: [] }; }); const ids = workspaces.map(({ id }) => id); return ids.map(id => ({ @@ -41,10 +39,13 @@ async function getCloudWorkspaceList() { flavour: WorkspaceFlavour.AFFINE_CLOUD, })); } catch (err) { - if (err instanceof Array && err[0]?.message === 'Forbidden resource') { + console.log(err); + const e = findGraphQLError(err, e => e.extensions.code === 401); + if (e) { // user not logged in return []; } + throw err; } } @@ -70,13 +71,13 @@ export class CloudWorkspaceListProvider implements WorkspaceListProvider { } async create( initial: ( - workspace: BlockSuiteWorkspace, + docCollection: DocCollection, blobStorage: BlobStorage ) => Promise ): Promise { const tempId = nanoid(); - const workspace = new BlockSuiteWorkspace({ + const docCollection = new DocCollection({ id: tempId, idGenerator: () => nanoid(), schema: globalBlockSuiteSchema, @@ -98,11 +99,11 @@ export class CloudWorkspaceListProvider implements WorkspaceListProvider { : new IndexedDBSyncStorage(workspaceId); // apply initial state - await initial(workspace, blobStorage); + await initial(docCollection, blobStorage); // save workspace to local storage, should be vary fast - await syncStorage.push(workspaceId, encodeStateAsUpdate(workspace.doc)); - for (const subdocs of workspace.doc.getSubdocs()) { + await syncStorage.push(workspaceId, encodeStateAsUpdate(docCollection.doc)); + for (const subdocs of docCollection.doc.getSubdocs()) { await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs)); } @@ -166,7 +167,7 @@ export class CloudWorkspaceListProvider implements WorkspaceListProvider { return; } - const bs = new BlockSuiteWorkspace({ + const bs = new DocCollection({ id, schema: globalBlockSuiteSchema, }); diff --git a/packages/frontend/workspace-impl/src/cloud/sync.ts b/packages/frontend/workspace-impl/src/cloud/sync.ts index 553bd8174b..1ce7349640 100644 --- a/packages/frontend/workspace-impl/src/cloud/sync.ts +++ b/packages/frontend/workspace-impl/src/cloud/sync.ts @@ -12,6 +12,8 @@ import { base64ToUint8Array, uint8ArrayToBase64 } from '../utils/base64'; const logger = new DebugLogger('affine:storage:socketio'); +(window as any)._TEST_SIMULATE_SYNC_LAG = Promise.resolve(); + export class AffineSyncStorage implements SyncStorage { name = 'affine-cloud'; @@ -57,6 +59,9 @@ export class AffineSyncStorage implements SyncStorage { docId: string, state: Uint8Array ): Promise<{ data: Uint8Array; state?: Uint8Array } | null> { + // for testing + await (window as any)._TEST_SIMULATE_SYNC_LAG; + const stateVector = state ? await uint8ArrayToBase64(state) : undefined; logger.debug('doc-load-v2', { diff --git a/packages/frontend/workspace-impl/src/local/__tests__/engine.spec.ts b/packages/frontend/workspace-impl/src/local/__tests__/engine.spec.ts index cee10dd5d3..a5f3f87ba6 100644 --- a/packages/frontend/workspace-impl/src/local/__tests__/engine.spec.ts +++ b/packages/frontend/workspace-impl/src/local/__tests__/engine.spec.ts @@ -1,7 +1,7 @@ import 'fake-indexeddb/auto'; import { AffineSchemas } from '@blocksuite/blocks/schemas'; -import { Schema, Workspace } from '@blocksuite/store'; +import { DocCollection, Schema } from '@blocksuite/store'; import { SyncEngine, SyncEngineStep, SyncPeerStep } from '@toeverything/infra'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { Doc } from 'yjs'; @@ -21,23 +21,23 @@ describe('SyncEngine', () => { test('basic - indexeddb', async () => { let prev: any; { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test - syncengine - indexeddb', schema, }); const syncEngine = new SyncEngine( - workspace.doc, - new IndexedDBSyncStorage(workspace.doc.guid), + docCollection.doc, + new IndexedDBSyncStorage(docCollection.doc.guid), [ - new IndexedDBSyncStorage(workspace.doc.guid + '1'), - new IndexedDBSyncStorage(workspace.doc.guid + '2'), + new IndexedDBSyncStorage(docCollection.doc.guid + '1'), + new IndexedDBSyncStorage(docCollection.doc.guid + '2'), ] ); syncEngine.start(); - const page = workspace.createDoc({ + const page = docCollection.createDoc({ id: 'page0', }); page.load(); @@ -64,61 +64,61 @@ describe('SyncEngine', () => { ); await syncEngine.waitForSynced(); syncEngine.forceStop(); - prev = workspace.doc.toJSON(); + prev = docCollection.doc.toJSON(); } { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test - syncengine - indexeddb', schema, }); const syncEngine = new SyncEngine( - workspace.doc, - new IndexedDBSyncStorage(workspace.doc.guid), + docCollection.doc, + new IndexedDBSyncStorage(docCollection.doc.guid), [] ); syncEngine.start(); await syncEngine.waitForSynced(); - expect(workspace.doc.toJSON()).toEqual({ + expect(docCollection.doc.toJSON()).toEqual({ ...prev, }); syncEngine.forceStop(); } { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test - syncengine - indexeddb', schema, }); const syncEngine = new SyncEngine( - workspace.doc, - new IndexedDBSyncStorage(workspace.doc.guid + '1'), + docCollection.doc, + new IndexedDBSyncStorage(docCollection.doc.guid + '1'), [] ); syncEngine.start(); await syncEngine.waitForSynced(); - expect(workspace.doc.toJSON()).toEqual({ + expect(docCollection.doc.toJSON()).toEqual({ ...prev, }); syncEngine.forceStop(); } { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test - syncengine - indexeddb', schema, }); const syncEngine = new SyncEngine( - workspace.doc, - new IndexedDBSyncStorage(workspace.doc.guid + '2'), + docCollection.doc, + new IndexedDBSyncStorage(docCollection.doc.guid + '2'), [] ); syncEngine.start(); await syncEngine.waitForSynced(); - expect(workspace.doc.toJSON()).toEqual({ + expect(docCollection.doc.toJSON()).toEqual({ ...prev, }); syncEngine.forceStop(); diff --git a/packages/frontend/workspace-impl/src/local/__tests__/peer.spec.ts b/packages/frontend/workspace-impl/src/local/__tests__/peer.spec.ts index e18124b4bb..1d2ecce04a 100644 --- a/packages/frontend/workspace-impl/src/local/__tests__/peer.spec.ts +++ b/packages/frontend/workspace-impl/src/local/__tests__/peer.spec.ts @@ -1,7 +1,7 @@ import 'fake-indexeddb/auto'; import { AffineSchemas } from '@blocksuite/blocks/schemas'; -import { Schema, Workspace } from '@blocksuite/store'; +import { DocCollection, Schema } from '@blocksuite/store'; import { SyncPeer, SyncPeerStep } from '@toeverything/infra'; import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -19,19 +19,19 @@ describe('SyncPeer', () => { test('basic - indexeddb', async () => { let prev: any; { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test - syncpeer - indexeddb', schema, }); const syncPeer = new SyncPeer( - workspace.doc, - new IndexedDBSyncStorage(workspace.doc.guid) + docCollection.doc, + new IndexedDBSyncStorage(docCollection.doc.guid) ); await syncPeer.waitForLoaded(); - const page = workspace.createDoc({ + const page = docCollection.createDoc({ id: 'page0', }); page.load(); @@ -58,21 +58,21 @@ describe('SyncPeer', () => { ); await syncPeer.waitForSynced(); syncPeer.stop(); - prev = workspace.doc.toJSON(); + prev = docCollection.doc.toJSON(); } { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test - syncpeer - indexeddb', schema, }); const syncPeer = new SyncPeer( - workspace.doc, - new IndexedDBSyncStorage(workspace.doc.guid) + docCollection.doc, + new IndexedDBSyncStorage(docCollection.doc.guid) ); await syncPeer.waitForSynced(); - expect(workspace.doc.toJSON()).toEqual({ + expect(docCollection.doc.toJSON()).toEqual({ ...prev, }); syncPeer.stop(); @@ -80,21 +80,21 @@ describe('SyncPeer', () => { }); test('status', async () => { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test - syncpeer - status', schema, }); const syncPeer = new SyncPeer( - workspace.doc, - new IndexedDBSyncStorage(workspace.doc.guid) + docCollection.doc, + new IndexedDBSyncStorage(docCollection.doc.guid) ); expect(syncPeer.status.step).toBe(SyncPeerStep.LoadingRootDoc); await syncPeer.waitForSynced(); expect(syncPeer.status.step).toBe(SyncPeerStep.Synced); - const page = workspace.createDoc({ + const page = docCollection.createDoc({ id: 'page0', }); expect(syncPeer.status.step).toBe(SyncPeerStep.LoadingSubDoc); diff --git a/packages/frontend/workspace-impl/src/local/list.ts b/packages/frontend/workspace-impl/src/local/list.ts index ea616586d6..faf26a5215 100644 --- a/packages/frontend/workspace-impl/src/local/list.ts +++ b/packages/frontend/workspace-impl/src/local/list.ts @@ -1,6 +1,6 @@ import { apis } from '@affine/electron-api'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import type { WorkspaceListProvider } from '@toeverything/infra'; import { type BlobStorage, @@ -53,7 +53,7 @@ export class LocalWorkspaceListProvider implements WorkspaceListProvider { async create( initial: ( - workspace: BlockSuiteWorkspace, + docCollection: DocCollection, blobStorage: BlobStorage ) => Promise ): Promise { @@ -66,7 +66,7 @@ export class LocalWorkspaceListProvider implements WorkspaceListProvider { ? new SQLiteSyncStorage(id) : new IndexedDBSyncStorage(id); - const workspace = new BlockSuiteWorkspace({ + const workspace = new DocCollection({ id: id, idGenerator: () => nanoid(), schema: globalBlockSuiteSchema, @@ -136,7 +136,7 @@ export class LocalWorkspaceListProvider implements WorkspaceListProvider { return; } - const bs = new BlockSuiteWorkspace({ + const bs = new DocCollection({ id, schema: globalBlockSuiteSchema, }); diff --git a/packages/frontend/workspace-impl/src/local/sync-sqlite.ts b/packages/frontend/workspace-impl/src/local/sync-sqlite.ts index 5372ca9439..17a2a777f3 100644 --- a/packages/frontend/workspace-impl/src/local/sync-sqlite.ts +++ b/packages/frontend/workspace-impl/src/local/sync-sqlite.ts @@ -20,6 +20,13 @@ export class SQLiteSyncStorage implements SyncStorage { ); if (update) { + if ( + update.byteLength === 0 || + (update.byteLength === 2 && update[0] === 0 && update[1] === 0) + ) { + return null; + } + return { data: update, state: encodeStateVectorFromUpdate(update), diff --git a/scripts/setup/lottie-web.ts b/scripts/setup/lottie-web.ts index 19d5beb6c7..17e1f7df79 100644 --- a/scripts/setup/lottie-web.ts +++ b/scripts/setup/lottie-web.ts @@ -6,6 +6,11 @@ vi.mock('lottie-web', () => ({ vi.mock('@blocksuite/presets', () => ({ AffineEditorContainer: vi.fn(), + BiDirectionalLinkPanel: vi.fn(), + DocMetaTags: vi.fn(), + DocTitle: vi.fn(), + EdgelessEditor: vi.fn(), + PageEditor: vi.fn(), })); if (typeof window !== 'undefined' && HTMLCanvasElement) { diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index 3ad004675a..016fb55441 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -15,7 +15,10 @@ import { waitForEditorLoad, } from '@affine-test/kit/utils/page-logic'; import { clickUserInfoCard } from '@affine-test/kit/utils/setting'; -import { clickSideBarSettingButton } from '@affine-test/kit/utils/sidebar'; +import { + clickSideBarCurrentWorkspaceBanner, + clickSideBarSettingButton, +} from '@affine-test/kit/utils/sidebar'; import { createLocalWorkspace } from '@affine-test/kit/utils/workspace'; import { expect } from '@playwright/test'; @@ -294,3 +297,72 @@ test('can sync svg between different browsers', async ({ page, browser }) => { expect(svg2).toEqual(svg1); } }); + +test('When the first sync is not completed, should always show loading', async ({ + page, + browser, +}) => { + await page.reload(); + await waitForEditorLoad(page); + await createLocalWorkspace( + { + name: 'test', + }, + page + ); + await enableCloudWorkspace(page); + await clickNewPageButton(page); + await waitForEditorLoad(page); + const title = getBlockSuiteEditorTitle(page); + await title.pressSequentially('TEST TITLE', { + delay: 50, + }); + + const context = await browser.newContext(); + await skipOnboarding(context); + const page2 = await context.newPage(); + await loginUser(page2, user.email); + + // simulate sync stuck + await page2.evaluate(() => { + (window as any)._TEST_SIMULATE_SYNC_LAG = new Promise(() => {}); + }); + const localWorkspaceUrl = page2.url(); + await clickSideBarCurrentWorkspaceBanner(page2); + await page2.getByTestId('workspace-card').getByText('test').click(); // enter "test" workspace + + await page2.waitForTimeout(1000); + + await expect( + page2.getByTestId('page-list-item').getByText('TEST TITLE') + ).not.toBeVisible(); // should be loading + + // Simulate user refresh and re-enter workspace, should still be loading + await page2.goto(localWorkspaceUrl); + + // setup sync lag + await page2.evaluate(() => { + (window as any).resolveSyncLag = null; + (window as any)._TEST_SIMULATE_SYNC_LAG = new Promise(resolve => { + (window as any).resolveSyncLag = resolve; + }); + }); + await clickSideBarCurrentWorkspaceBanner(page2); + await page2.getByTestId('workspace-card').getByText('test').click(); // enter "test" workspace + + await page2.waitForTimeout(1000); + + await expect( + page2.getByTestId('page-list-item').getByText('TEST TITLE') + ).not.toBeVisible(); // should be loading + + await page2.evaluate(() => { + (window as any).resolveSyncLag(); + }); // start syncing + await page2.getByTestId('page-list-item').getByText('TEST TITLE').click(); // should be able to click page + await waitForEditorLoad(page2); + + expect(await getBlockSuiteEditorTitle(page2).innerText()).toContain( + 'TEST TITLE' + ); +}); diff --git a/tests/affine-cloud/e2e/login.spec.ts b/tests/affine-cloud/e2e/login.spec.ts index 6bfca65460..6ede51db77 100644 --- a/tests/affine-cloud/e2e/login.spec.ts +++ b/tests/affine-cloud/e2e/login.spec.ts @@ -73,6 +73,7 @@ test.describe('login first', () => { await page.getByTestId('workspace-modal-account-option').click(); 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(); diff --git a/tests/affine-cloud/e2e/page-history.spec.ts b/tests/affine-cloud/e2e/page-history.spec.ts index 14ed1a7734..71d0ec31e6 100644 --- a/tests/affine-cloud/e2e/page-history.spec.ts +++ b/tests/affine-cloud/e2e/page-history.spec.ts @@ -57,7 +57,7 @@ test('newly created page shows empty history', async ({ page }) => { const pushCurrentPageUpdates = async (page: Page) => { const [workspaceId, guid, updates, state] = await page.evaluate(() => { // @ts-expect-error - const Y = window.currentWorkspace.blockSuiteWorkspace.constructor.Y; + const Y = window.currentWorkspace.docCollection.constructor.Y; // @ts-expect-error const spaceDoc = window.currentEditor.page.spaceDoc; // @ts-expect-error diff --git a/tests/affine-local/e2e/all-page.spec.ts b/tests/affine-local/e2e/all-page.spec.ts index 16b92fe110..22b95169ce 100644 --- a/tests/affine-local/e2e/all-page.spec.ts +++ b/tests/affine-local/e2e/all-page.spec.ts @@ -210,7 +210,7 @@ test('select two pages and delete', async ({ page }) => { // the floating popover should appear await expect(page.locator('[data-testid="floating-toolbar"]')).toBeVisible(); await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText( - '2 selected' + '2 doc(s) selected' ); // click delete button @@ -253,6 +253,6 @@ test('select a group of items by clicking "Select All" in group header', async ( // check the selected count is equal to the one displayed in the floating toolbar await expect(page.locator('[data-testid="floating-toolbar"]')).toHaveText( - `${selectedItemCount} selected` + `${selectedItemCount} doc(s) selected` ); }); diff --git a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts index faeb92bbe5..46f50b791b 100644 --- a/tests/affine-local/e2e/local-first-delete-workspace.spec.ts +++ b/tests/affine-local/e2e/local-first-delete-workspace.spec.ts @@ -24,6 +24,9 @@ test('Create new workspace, then delete it', async ({ page, workspace }) => { await openSettingModal(page); await openWorkspaceSettingPanel(page, 'Test Workspace'); await page.getByTestId('delete-workspace-button').click(); + await expect( + page.getByTestId('affine-notification').first() + ).not.toBeVisible(); const workspaceNameDom = page.getByTestId('workspace-name'); const currentWorkspaceName = (await workspaceNameDom.evaluate( node => node.textContent diff --git a/tests/kit/playwright.ts b/tests/kit/playwright.ts index 6279afbff1..c36a3863c9 100644 --- a/tests/kit/playwright.ts +++ b/tests/kit/playwright.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path, { resolve } from 'node:path'; import process from 'node:process'; -import type { Workspace } from '@blocksuite/store'; +import type { DocCollection } from '@blocksuite/store'; import { type BrowserContext, test as baseTest } from '@playwright/test'; export const rootDir = resolve(__dirname, '..', '..'); @@ -27,9 +27,9 @@ function generateUUID() { export const enableCoverage = !!process.env.CI || !!process.env.COVERAGE; -type CurrentWorkspace = { +type CurrentDocCollection = { meta: { id: string; flavour: string }; - blockSuiteWorkspace: Workspace; + docCollection: DocCollection; }; export const skipOnboarding = async (context: BrowserContext) => { @@ -43,7 +43,7 @@ export const skipOnboarding = async (context: BrowserContext) => { export const test = baseTest.extend<{ workspace: { - current: () => Promise; + current: () => Promise; }; }>({ workspace: async ({ page }, use) => { diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index 7a54d1e91c..b73296a188 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -33,9 +33,7 @@ export async function getLatestMailMessage() { export async function getLoginCookie( context: BrowserContext ): Promise { - return (await context.cookies()).find( - c => c.name === 'next-auth.session-token' - ); + return (await context.cookies()).find(c => c.name === 'sid'); } const cloudUserSchema = z.object({ @@ -106,7 +104,7 @@ export async function createRandomUser(): Promise<{ await client.user.create({ data: { ...user, - emailVerified: new Date(), + emailVerifiedAt: new Date(), password: await hash(user.password), features: { create: { diff --git a/tests/storybook/.storybook/preview.tsx b/tests/storybook/.storybook/preview.tsx index 9c5b0ade89..8d863c68dc 100644 --- a/tests/storybook/.storybook/preview.tsx +++ b/tests/storybook/.storybook/preview.tsx @@ -1,10 +1,6 @@ import '@affine/component/theme/global.css'; import '@affine/component/theme/theme.css'; import { createI18n } from '@affine/i18n'; -import MockSessionContext, { - mockAuthStates, - // @ts-ignore -} from '@tomfreudenberg/next-auth-mock'; import { ThemeProvider, useTheme } from 'next-themes'; import { useDarkMode } from 'storybook-dark-mode'; import { AffineContext } from '@affine/component/context'; @@ -40,51 +36,6 @@ export const parameters = { }, }; -const SB_PARAMETER_KEY = 'nextAuthMock'; -export const mockAuthPreviewToolbarItem = ({ - name = 'mockAuthState', - description = 'Set authentication state', - defaultValue = null, - icon = 'user', - items = mockAuthStates, -} = {}) => { - return { - mockAuthState: { - name, - description, - defaultValue, - toolbar: { - icon, - items: Object.keys(items).map(e => ({ - value: e, - title: items[e].title, - })), - }, - }, - }; -}; - -export const withMockAuth: Decorator = (Story, context) => { - // Set a session value for mocking - const session = (() => { - // Allow overwrite of session value by parameter in story - const paramValue = context?.parameters[SB_PARAMETER_KEY]; - if (typeof paramValue?.session === 'string') { - return mockAuthStates[paramValue.session]?.session; - } else { - return paramValue?.session - ? paramValue.session - : mockAuthStates[context.globals.mockAuthState]?.session; - } - })(); - - return ( - - - - ); -}; - const i18n = createI18n(); const withI18n: Decorator = (Story, context) => { const locale = context.globals.locale; @@ -198,7 +149,6 @@ const withPlatformSelectionDecorator: Decorator = (Story, context) => { const decorators = [ withContextDecorator, withI18n, - withMockAuth, withPlatformSelectionDecorator, ]; diff --git a/tests/storybook/package.json b/tests/storybook/package.json index b2595ceb7f..23d97f10c2 100644 --- a/tests/storybook/package.json +++ b/tests/storybook/package.json @@ -22,14 +22,14 @@ "storybook-addon-react-router-v6": "^2.0.10" }, "devDependencies": { - "@blocksuite/block-std": "0.13.0-canary-202403050653-934469c", - "@blocksuite/blocks": "0.13.0-canary-202403050653-934469c", - "@blocksuite/global": "0.13.0-canary-202403050653-934469c", - "@blocksuite/icons": "2.1.44", - "@blocksuite/inline": "0.13.0-canary-202403050653-934469c", - "@blocksuite/lit": "0.13.0-canary-202403050653-934469c", - "@blocksuite/presets": "0.13.0-canary-202403050653-934469c", - "@blocksuite/store": "0.13.0-canary-202403050653-934469c", + "@blocksuite/block-std": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/blocks": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/global": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/icons": "2.1.45", + "@blocksuite/inline": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/lit": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/presets": "0.13.0-canary-202403140320-a2b362b", + "@blocksuite/store": "0.13.0-canary-202403140320-a2b362b", "@storybook/addon-actions": "^7.6.17", "@storybook/addon-essentials": "^7.6.17", "@storybook/addon-interactions": "^7.6.17", @@ -39,8 +39,7 @@ "@storybook/builder-vite": "^7.6.17", "@storybook/react": "^7.6.17", "@storybook/react-vite": "^7.6.17", - "@storybook/test-runner": "^0.16.0", - "@tomfreudenberg/next-auth-mock": "^0.5.6", + "@storybook/test-runner": "^0.17.0", "@vanilla-extract/esbuild-plugin": "^2.3.5", "@vitejs/plugin-react": "^4.2.1", "chromatic": "^11.0.0", diff --git a/tests/storybook/src/stories/blocksuite-editor.stories.tsx b/tests/storybook/src/stories/blocksuite-editor.stories.tsx index b21017eeba..39c62f435a 100644 --- a/tests/storybook/src/stories/blocksuite-editor.stories.tsx +++ b/tests/storybook/src/stories/blocksuite-editor.stories.tsx @@ -1,6 +1,6 @@ import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor'; import { AffineSchemas } from '@blocksuite/blocks/schemas'; -import { Workspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import { Schema } from '@blocksuite/store'; import type { StoryFn } from '@storybook/react'; import { initEmptyPage } from '@toeverything/infra'; @@ -9,14 +9,14 @@ const schema = new Schema(); schema.register(AffineSchemas); async function createAndInitPage( - workspace: Workspace, + docCollection: DocCollection, title: string, preview: string ) { - const page = workspace.createDoc(); - initEmptyPage(page, title); - page.getBlockByFlavour('affine:paragraph').at(0)?.text?.insert(preview, 0); - return page; + const doc = docCollection.createDoc(); + initEmptyPage(doc, title); + doc.getBlockByFlavour('affine:paragraph').at(0)?.text?.insert(preview, 0); + return doc; } export default { @@ -33,26 +33,26 @@ export const DocEditor: StoryFn = (_, { loaded }) => { DocEditor.loaders = [ async () => { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test-workspace-id', schema, }); - workspace.doc.emit('sync', []); - workspace.meta.setProperties({ + docCollection.doc.emit('sync', []); + docCollection.meta.setProperties({ tags: { options: [], }, }); const page = await createAndInitPage( - workspace, + docCollection, 'This is page 1', 'Hello World from page 1' ); return { page, - workspace, + workspace: docCollection, }; }, ]; diff --git a/tests/storybook/src/stories/image-preview-modal.stories.tsx b/tests/storybook/src/stories/image-preview-modal.stories.tsx index 0f6cffbebc..914465c18e 100644 --- a/tests/storybook/src/stories/image-preview-modal.stories.tsx +++ b/tests/storybook/src/stories/image-preview-modal.stories.tsx @@ -24,7 +24,7 @@ export const Default = () => { const [page, setPage] = useState(null); useEffect(() => { - const bsPage = workspace.blockSuiteWorkspace.createDoc('page0'); + const bsPage = workspace.docCollection.createDoc('page0'); initEmptyPage(bsPage); const { page, release } = pageManager.open(bsPage.meta!.id); @@ -32,7 +32,7 @@ export const Default = () => { fetch(new URL('@affine-test/fixtures/large-image.png', import.meta.url)) .then(res => res.arrayBuffer()) .then(async buffer => { - const id = await workspace.blockSuiteWorkspace.blob.set( + const id = await workspace.docCollection.blob.set( new Blob([buffer], { type: 'image/png' }) ); const frameId = bsPage.getBlockByFlavour('affine:note')[0].id; @@ -80,7 +80,7 @@ export const Default = () => { {createPortal( , document.body )} diff --git a/tests/storybook/src/stories/page-info-properties.stories.tsx b/tests/storybook/src/stories/page-info-properties.stories.tsx index ba08e4eb71..55e69a621a 100644 --- a/tests/storybook/src/stories/page-info-properties.stories.tsx +++ b/tests/storybook/src/stories/page-info-properties.stories.tsx @@ -1,6 +1,6 @@ import { PagePropertiesTable } from '@affine/core/components/affine/page-properties'; import { AffineSchemas } from '@blocksuite/blocks/schemas'; -import { Workspace } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; import { Schema } from '@blocksuite/store'; import type { StoryFn } from '@storybook/react'; import { initEmptyPage } from '@toeverything/infra'; @@ -9,11 +9,11 @@ const schema = new Schema(); schema.register(AffineSchemas); async function createAndInitPage( - workspace: Workspace, + docCollection: DocCollection, title: string, preview: string ) { - const page = workspace.createDoc(); + const page = docCollection.createDoc(); initEmptyPage(page, title); page.getBlockByFlavour('affine:paragraph').at(0)?.text?.insert(preview, 0); return page; @@ -36,19 +36,19 @@ export const PageInfoProperties: StoryFn = ( PageInfoProperties.loaders = [ async () => { - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test-workspace-id', schema, }); - workspace.doc.emit('sync', []); - workspace.meta.setProperties({ + docCollection.doc.emit('sync', []); + docCollection.meta.setProperties({ tags: { options: [], }, }); const page = await createAndInitPage( - workspace, + docCollection, 'This is page 1', 'Hello World from page 1' ); @@ -59,7 +59,7 @@ PageInfoProperties.loaders = [ return { page, - workspace, + workspace: docCollection, }; }, ]; diff --git a/tests/storybook/src/stories/page-list.stories.tsx b/tests/storybook/src/stories/page-list.stories.tsx index 5c5bccc770..fb9a6e208d 100644 --- a/tests/storybook/src/stories/page-list.stories.tsx +++ b/tests/storybook/src/stories/page-list.stories.tsx @@ -16,7 +16,7 @@ import { import { workbenchRoutes } from '@affine/core/router'; import { AffineSchemas } from '@blocksuite/blocks/schemas'; import { PageIcon, TagsIcon } from '@blocksuite/icons'; -import { Schema, Workspace } from '@blocksuite/store'; +import { DocCollection, Schema } from '@blocksuite/store'; import { expect } from '@storybook/jest'; import type { Meta, StoryFn } from '@storybook/react'; import { userEvent } from '@storybook/testing-library'; @@ -237,49 +237,49 @@ PageListStory.argTypes = { }; async function createAndInitPage( - workspace: Workspace, + docCollection: DocCollection, title: string, preview: string ) { - const page = workspace.createDoc(); - initEmptyPage(page, title); - page.getBlockByFlavour('affine:paragraph').at(0)?.text?.insert(preview, 0); - return page; + const doc = docCollection.createDoc(); + initEmptyPage(doc, title); + doc.getBlockByFlavour('affine:paragraph').at(0)?.text?.insert(preview, 0); + return doc; } PageListStory.loaders = [ async () => { const schema = new Schema(); schema.register(AffineSchemas); - const workspace = new Workspace({ + const docCollection = new DocCollection({ id: 'test-workspace-id', schema, }); - workspace.meta.setProperties({ + docCollection.meta.setProperties({ tags: { options: structuredClone(testTags), }, }); const page1 = await createAndInitPage( - workspace, + docCollection, 'This is page 1', 'Hello World from page 1' ); const page2 = await createAndInitPage( - workspace, + docCollection, 'This is page 2', 'Hello World from page 2' ); const page3 = await createAndInitPage( - workspace, + docCollection, 'This is page 3', 'Hello World from page 3Hello World from page 3Hello World from page 3Hello World from page 3Hello World from page 3' ); await createAndInitPage( - workspace, + docCollection, 'This is page 4', 'Hello World from page 3Hello World from page 3Hello World from page 3Hello World from page 3Hello World from page 3' ); @@ -288,12 +288,12 @@ PageListStory.loaders = [ page2.meta!.createDate = page2.meta!.createDate - 3600 * 1000 * 24; page3.meta!.createDate = page3.meta!.createDate - 3600 * 1000 * 24 * 7; - workspace.meta.docMetas[3].tags = testTags.slice(0, 3).map(t => t.id); - workspace.meta.docMetas[2].tags = testTags.slice(0, 12).map(t => t.id); + docCollection.meta.docMetas[3].tags = testTags.slice(0, 3).map(t => t.id); + docCollection.meta.docMetas[2].tags = testTags.slice(0, 12).map(t => t.id); return { - blockSuiteWorkspace: workspace, - pages: workspace.meta.docs, + blockSuiteWorkspace: docCollection, + pages: docCollection.meta.docs, }; }, ]; diff --git a/tests/storybook/src/stories/share-menu.stories.tsx b/tests/storybook/src/stories/share-menu.stories.tsx index 2ab43017a3..73ebdfe727 100644 --- a/tests/storybook/src/stories/share-menu.stories.tsx +++ b/tests/storybook/src/stories/share-menu.stories.tsx @@ -29,7 +29,7 @@ export const Basic: StoryFn = () => { const [page, setPage] = useState(null); useEffect(() => { - const page = workspace.blockSuiteWorkspace.createDoc(nanoid()); + const page = workspace.docCollection.createDoc(nanoid()); initEmptyPage(page); setPage(page); @@ -71,7 +71,7 @@ export const AffineBasic: StoryFn = () => { const [page, setPage] = useState(null); useEffect(() => { - const page = workspace.blockSuiteWorkspace.createDoc(nanoid()); + const page = workspace.docCollection.createDoc(nanoid()); initEmptyPage(page); setPage(page); diff --git a/vitest.config.ts b/vitest.config.ts index fbfe90d02a..a2757c587b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; import react from '@vitejs/plugin-react-swc'; +import * as fg from 'fast-glob'; import { defineConfig } from 'vitest/config'; const rootDir = fileURLToPath(new URL('.', import.meta.url)); @@ -26,10 +27,9 @@ export default defineConfig({ resolve(rootDir, './scripts/setup/global.ts'), ], include: [ - resolve(rootDir, 'packages/common/**/*.spec.ts'), - resolve(rootDir, 'packages/common/**/*.spec.tsx'), - resolve(rootDir, 'packages/frontend/**/*.spec.ts'), - resolve(rootDir, 'packages/frontend/**/*.spec.tsx'), + // rootDir cannot be used as a pattern on windows + fg.convertPathToPattern(rootDir) + + 'packages/{common,frontend}/**/*.spec.{ts,tsx}', ], exclude: [ '**/node_modules', diff --git a/yarn.lock b/yarn.lock index fe56d56e18..91c20d561b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -202,12 +202,12 @@ __metadata: "@affine/electron-api": "workspace:*" "@affine/graphql": "workspace:*" "@affine/i18n": "workspace:*" - "@blocksuite/blocks": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/icons": "npm:2.1.44" - "@blocksuite/lit": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/presets": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/blocks": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/icons": "npm:2.1.45" + "@blocksuite/lit": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/presets": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" "@dnd-kit/core": "npm:^6.1.0" "@dnd-kit/modifiers": "npm:^7.0.0" "@dnd-kit/sortable": "npm:^8.0.0" @@ -238,7 +238,7 @@ __metadata: "@storybook/jest": "npm:^0.2.3" "@storybook/react": "npm:^7.6.17" "@storybook/react-vite": "npm:^7.6.17" - "@storybook/test-runner": "npm:^0.16.0" + "@storybook/test-runner": "npm:^0.17.0" "@storybook/testing-library": "npm:^0.2.2" "@testing-library/react": "npm:^14.2.1" "@toeverything/theme": "npm:^0.7.29" @@ -262,7 +262,7 @@ __metadata: lottie-react: "npm:^2.4.0" lottie-web: "npm:^5.12.2" nanoid: "npm:^5.0.6" - next-themes: "npm:^0.2.1" + next-themes: "npm:^0.3.0" react: "npm:18.2.0" react-dom: "npm:18.2.0" react-error-boundary: "npm:^4.0.12" @@ -302,15 +302,15 @@ __metadata: "@affine/i18n": "workspace:*" "@affine/templates": "workspace:*" "@affine/workspace-impl": "workspace:*" - "@aws-sdk/client-s3": "npm:3.525.0" - "@blocksuite/block-std": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/blocks": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/icons": "npm:2.1.44" - "@blocksuite/inline": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/lit": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/presets": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" + "@aws-sdk/client-s3": "npm:3.529.1" + "@blocksuite/block-std": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/blocks": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/icons": "npm:2.1.45" + "@blocksuite/inline": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/lit": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/presets": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" "@dnd-kit/core": "npm:^6.1.0" "@dnd-kit/modifiers": "npm:^7.0.0" "@dnd-kit/sortable": "npm:^8.0.0" @@ -374,8 +374,7 @@ __metadata: mime-types: "npm:^2.1.35" mini-css-extract-plugin: "npm:^2.8.0" nanoid: "npm:^5.0.6" - next-auth: "npm:^4.24.5" - next-themes: "npm:^0.2.1" + next-themes: "npm:^0.3.0" postcss-loader: "npm:^8.1.0" raw-loader: "npm:^4.0.2" react: "npm:18.2.0" @@ -441,10 +440,10 @@ __metadata: "@affine-test/kit": "workspace:*" "@affine/env": "workspace:*" "@affine/native": "workspace:*" - "@blocksuite/blocks": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/lit": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/presets": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/blocks": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/lit": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/presets": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" "@electron-forge/cli": "npm:^7.3.0" "@electron-forge/core": "npm:^7.3.0" "@electron-forge/core-utils": "npm:^7.3.0" @@ -492,8 +491,8 @@ __metadata: version: 0.0.0-use.local resolution: "@affine/env@workspace:packages/common/env" dependencies: - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" lit: "npm:^3.1.2" react: "npm:18.2.0" react-dom: "npm:18.2.0" @@ -549,7 +548,7 @@ __metadata: "@faker-js/faker": "npm:^8.4.1" "@istanbuljs/schema": "npm:^0.1.3" "@magic-works/i18n-codegen": "npm:^0.5.0" - "@nx/vite": "npm:18.0.7" + "@nx/vite": "npm:18.0.8" "@playwright/test": "npm:^1.41.2" "@taplo/cli": "npm:^0.7.0" "@testing-library/react": "npm:^14.2.1" @@ -591,7 +590,7 @@ __metadata: ts-node: "npm:^10.9.2" typescript: "npm:^5.3.3" vite: "npm:^5.1.4" - vite-plugin-istanbul: "npm:^5.0.0" + vite-plugin-istanbul: "npm:^6.0.0" vite-plugin-static-copy: "npm:^1.0.1" vitest: "npm:1.3.1" vitest-fetch-mock: "npm:^0.2.2" @@ -652,11 +651,11 @@ __metadata: "@opentelemetry/exporter-zipkin": "npm:^1.21.0" "@opentelemetry/host-metrics": "npm:^0.35.0" "@opentelemetry/instrumentation": "npm:^0.49.0" - "@opentelemetry/instrumentation-graphql": "npm:^0.37.0" + "@opentelemetry/instrumentation-graphql": "npm:^0.38.0" "@opentelemetry/instrumentation-http": "npm:^0.49.0" - "@opentelemetry/instrumentation-ioredis": "npm:^0.37.0" - "@opentelemetry/instrumentation-nestjs-core": "npm:^0.34.0" - "@opentelemetry/instrumentation-socket.io": "npm:^0.36.0" + "@opentelemetry/instrumentation-ioredis": "npm:^0.38.0" + "@opentelemetry/instrumentation-nestjs-core": "npm:^0.35.0" + "@opentelemetry/instrumentation-socket.io": "npm:^0.37.0" "@opentelemetry/resources": "npm:^1.21.0" "@opentelemetry/sdk-metrics": "npm:^1.21.0" "@opentelemetry/sdk-node": "npm:^0.49.0" @@ -696,7 +695,6 @@ __metadata: nanoid: "npm:^5.0.6" nest-commander: "npm:^3.12.5" nestjs-throttler-storage-redis: "npm:^0.4.1" - next-auth: "npm:^4.24.5" nodemailer: "npm:^6.9.10" nodemon: "npm:^3.1.0" on-headers: "npm:^1.0.2" @@ -740,14 +738,14 @@ __metadata: "@affine/component": "workspace:*" "@affine/i18n": "workspace:*" "@affine/workspace-impl": "workspace:*" - "@blocksuite/block-std": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/blocks": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/icons": "npm:2.1.44" - "@blocksuite/inline": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/lit": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/presets": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/block-std": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/blocks": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/icons": "npm:2.1.45" + "@blocksuite/inline": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/lit": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/presets": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" "@dnd-kit/sortable": "npm:^8.0.0" "@storybook/addon-actions": "npm:^7.6.17" "@storybook/addon-essentials": "npm:^7.6.17" @@ -759,9 +757,8 @@ __metadata: "@storybook/jest": "npm:^0.2.3" "@storybook/react": "npm:^7.6.17" "@storybook/react-vite": "npm:^7.6.17" - "@storybook/test-runner": "npm:^0.16.0" + "@storybook/test-runner": "npm:^0.17.0" "@storybook/testing-library": "npm:^0.2.2" - "@tomfreudenberg/next-auth-mock": "npm:^0.5.6" "@vanilla-extract/esbuild-plugin": "npm:^2.3.5" "@vitejs/plugin-react": "npm:^4.2.1" chromatic: "npm:^11.0.0" @@ -820,7 +817,6 @@ __metadata: is-svg: "npm:^5.0.0" lodash-es: "npm:^4.17.21" nanoid: "npm:^5.0.6" - next-auth: "npm:^4.24.5" socket.io-client: "npm:^4.7.4" vitest: "npm:1.3.1" ws: "npm:^8.16.0" @@ -1243,16 +1239,16 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/client-s3@npm:3.525.0, @aws-sdk/client-s3@npm:^3.515.0": - version: 3.525.0 - resolution: "@aws-sdk/client-s3@npm:3.525.0" +"@aws-sdk/client-s3@npm:3.529.1, @aws-sdk/client-s3@npm:^3.515.0": + version: 3.529.1 + resolution: "@aws-sdk/client-s3@npm:3.529.1" dependencies: "@aws-crypto/sha1-browser": "npm:3.0.0" "@aws-crypto/sha256-browser": "npm:3.0.0" "@aws-crypto/sha256-js": "npm:3.0.0" - "@aws-sdk/client-sts": "npm:3.525.0" - "@aws-sdk/core": "npm:3.525.0" - "@aws-sdk/credential-provider-node": "npm:3.525.0" + "@aws-sdk/client-sts": "npm:3.529.1" + "@aws-sdk/core": "npm:3.529.1" + "@aws-sdk/credential-provider-node": "npm:3.529.1" "@aws-sdk/middleware-bucket-endpoint": "npm:3.525.0" "@aws-sdk/middleware-expect-continue": "npm:3.523.0" "@aws-sdk/middleware-flexible-checksums": "npm:3.523.0" @@ -1303,20 +1299,19 @@ __metadata: "@smithy/util-stream": "npm:^2.1.3" "@smithy/util-utf8": "npm:^2.1.1" "@smithy/util-waiter": "npm:^2.1.3" - fast-xml-parser: "npm:4.2.5" tslib: "npm:^2.5.0" - checksum: 10/13ecd0c71ef488c49826b2c48a9e7c774fe6c703a49ae3a5d0fe1f6d179406c80548ff271ac68be88e906acb723d93b8d24a27dd6a7936266f0cdae5a52e1c25 + checksum: 10/97283c205b3d6f26c57e3a0d77024805d605cf33006c231b266d340b79335f3150f27c6648858fbfc2b0291719c0e2424f9a85b6ac18fe1d97d58280e5cdc57b languageName: node linkType: hard -"@aws-sdk/client-sso-oidc@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/client-sso-oidc@npm:3.525.0" +"@aws-sdk/client-sso-oidc@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/client-sso-oidc@npm:3.529.1" dependencies: "@aws-crypto/sha256-browser": "npm:3.0.0" "@aws-crypto/sha256-js": "npm:3.0.0" - "@aws-sdk/client-sts": "npm:3.525.0" - "@aws-sdk/core": "npm:3.525.0" + "@aws-sdk/client-sts": "npm:3.529.1" + "@aws-sdk/core": "npm:3.529.1" "@aws-sdk/middleware-host-header": "npm:3.523.0" "@aws-sdk/middleware-logger": "npm:3.523.0" "@aws-sdk/middleware-recursion-detection": "npm:3.523.0" @@ -1353,18 +1348,18 @@ __metadata: "@smithy/util-utf8": "npm:^2.1.1" tslib: "npm:^2.5.0" peerDependencies: - "@aws-sdk/credential-provider-node": ^3.525.0 - checksum: 10/9f27413c6105ceaf6121b597b471deefa4701faf7200c2852a54d200eb5fbf7c0f566cd71fc2ac4c0ffeb6534876b03bd8045fffe12e75a4d0b6f5fe359ecb92 + "@aws-sdk/credential-provider-node": ^3.529.1 + checksum: 10/b4653bf0b543ee99ddb77efc0fa7b727324ba515e76002c3d7d85a3662cbe2cf69094a6598609275154128a72a0d7e3373be6d30955b4445c3f9b419e4d20f8e languageName: node linkType: hard -"@aws-sdk/client-sso@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/client-sso@npm:3.525.0" +"@aws-sdk/client-sso@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/client-sso@npm:3.529.1" dependencies: "@aws-crypto/sha256-browser": "npm:3.0.0" "@aws-crypto/sha256-js": "npm:3.0.0" - "@aws-sdk/core": "npm:3.525.0" + "@aws-sdk/core": "npm:3.529.1" "@aws-sdk/middleware-host-header": "npm:3.523.0" "@aws-sdk/middleware-logger": "npm:3.523.0" "@aws-sdk/middleware-recursion-detection": "npm:3.523.0" @@ -1400,17 +1395,17 @@ __metadata: "@smithy/util-retry": "npm:^2.1.3" "@smithy/util-utf8": "npm:^2.1.1" tslib: "npm:^2.5.0" - checksum: 10/67f40839ad6162b9592a9dd6863d629756b68bda891b436f3cac9d877335e41b8682e61e1fbbe6bc1a0bcedb13a990d1f4397e47a7466fa3a5293b56bdb56e12 + checksum: 10/21ef07521cd628b6b1f595ec6b81b69b1b9fcdd8d482a15c08fcfa2a546a38300e466e3d10aadef7398da6de9da194855eb0bd44179e552af14a2102fe9f8d10 languageName: node linkType: hard -"@aws-sdk/client-sts@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/client-sts@npm:3.525.0" +"@aws-sdk/client-sts@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/client-sts@npm:3.529.1" dependencies: "@aws-crypto/sha256-browser": "npm:3.0.0" "@aws-crypto/sha256-js": "npm:3.0.0" - "@aws-sdk/core": "npm:3.525.0" + "@aws-sdk/core": "npm:3.529.1" "@aws-sdk/middleware-host-header": "npm:3.523.0" "@aws-sdk/middleware-logger": "npm:3.523.0" "@aws-sdk/middleware-recursion-detection": "npm:3.523.0" @@ -1445,25 +1440,25 @@ __metadata: "@smithy/util-middleware": "npm:^2.1.3" "@smithy/util-retry": "npm:^2.1.3" "@smithy/util-utf8": "npm:^2.1.1" - fast-xml-parser: "npm:4.2.5" tslib: "npm:^2.5.0" peerDependencies: - "@aws-sdk/credential-provider-node": ^3.525.0 - checksum: 10/d790b539ded8ef5794f481ada752d141b2c910b25398557f4313c87e0799c47182f102528434d3a59490aaed601ef7e30b3bada014a4e9f27d7b7d3edfafb671 + "@aws-sdk/credential-provider-node": ^3.529.1 + checksum: 10/42f0d2423218f99b0b76831a76508e084d7463c98416c12e28e2a3261714b26e4b64374fd2b4ce8e6f03b43bf6af8ac9286a122a0771095b6ce64f79aec15515 languageName: node linkType: hard -"@aws-sdk/core@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/core@npm:3.525.0" +"@aws-sdk/core@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/core@npm:3.529.1" dependencies: "@smithy/core": "npm:^1.3.5" "@smithy/protocol-http": "npm:^3.2.1" "@smithy/signature-v4": "npm:^2.1.3" "@smithy/smithy-client": "npm:^2.4.2" "@smithy/types": "npm:^2.10.1" + fast-xml-parser: "npm:4.2.5" tslib: "npm:^2.5.0" - checksum: 10/c4fa9be97227bca9d599237479ed718ed37ad5fbb9e851f1b62d27381294292eb1752063da3eb8a1b1141e86910396be2c932b231ea72da0f8b4afafbf490d26 + checksum: 10/2232baf730c6776853e58c7a58604bbaebe411daf503a798abd98562f6d2a85ef4bf2cce5fbbb0d3fdc1f5dc5ac76c404ceeff26a12ddd7040fa0b0372170897 languageName: node linkType: hard @@ -1496,42 +1491,42 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-ini@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/credential-provider-ini@npm:3.525.0" +"@aws-sdk/credential-provider-ini@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/credential-provider-ini@npm:3.529.1" dependencies: - "@aws-sdk/client-sts": "npm:3.525.0" + "@aws-sdk/client-sts": "npm:3.529.1" "@aws-sdk/credential-provider-env": "npm:3.523.0" "@aws-sdk/credential-provider-process": "npm:3.523.0" - "@aws-sdk/credential-provider-sso": "npm:3.525.0" - "@aws-sdk/credential-provider-web-identity": "npm:3.525.0" + "@aws-sdk/credential-provider-sso": "npm:3.529.1" + "@aws-sdk/credential-provider-web-identity": "npm:3.529.1" "@aws-sdk/types": "npm:3.523.0" "@smithy/credential-provider-imds": "npm:^2.2.3" "@smithy/property-provider": "npm:^2.1.3" "@smithy/shared-ini-file-loader": "npm:^2.3.3" "@smithy/types": "npm:^2.10.1" tslib: "npm:^2.5.0" - checksum: 10/53b9f943aaadde706e57cdcdd47861d33bea3b6626bf76baa5db2536c2c6600bc83cc18d51091216cb345c50f0a600f8276bce4244e98d5d2e28364fbbffadcb + checksum: 10/ba2a58837be55955063f3c1820145cf79ad129fb2e97092a16c8a42955d0f2470ad5379b0d5df9a5ca86c254ec90af40012036a697cb84e9223192df111bfbaa languageName: node linkType: hard -"@aws-sdk/credential-provider-node@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/credential-provider-node@npm:3.525.0" +"@aws-sdk/credential-provider-node@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/credential-provider-node@npm:3.529.1" dependencies: "@aws-sdk/credential-provider-env": "npm:3.523.0" "@aws-sdk/credential-provider-http": "npm:3.525.0" - "@aws-sdk/credential-provider-ini": "npm:3.525.0" + "@aws-sdk/credential-provider-ini": "npm:3.529.1" "@aws-sdk/credential-provider-process": "npm:3.523.0" - "@aws-sdk/credential-provider-sso": "npm:3.525.0" - "@aws-sdk/credential-provider-web-identity": "npm:3.525.0" + "@aws-sdk/credential-provider-sso": "npm:3.529.1" + "@aws-sdk/credential-provider-web-identity": "npm:3.529.1" "@aws-sdk/types": "npm:3.523.0" "@smithy/credential-provider-imds": "npm:^2.2.3" "@smithy/property-provider": "npm:^2.1.3" "@smithy/shared-ini-file-loader": "npm:^2.3.3" "@smithy/types": "npm:^2.10.1" tslib: "npm:^2.5.0" - checksum: 10/958be6ff7a04801a32e2969eeefbe34150daf5871b2914979ab7317f87d0f2a68fdf479d775e693a12a0e331993ca993c96b1cfa5e72a00aa41dc4781db6a86e + checksum: 10/126456d6362e3f4939015f3233764e74d3e0708c2247fb158541cfcc0df70ac1ebf785cfea06e181e25d6a2afa59f9cbbff95f8fdae57e83d2c4047339bdee72 languageName: node linkType: hard @@ -1548,31 +1543,31 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/credential-provider-sso@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/credential-provider-sso@npm:3.525.0" +"@aws-sdk/credential-provider-sso@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/credential-provider-sso@npm:3.529.1" dependencies: - "@aws-sdk/client-sso": "npm:3.525.0" - "@aws-sdk/token-providers": "npm:3.525.0" + "@aws-sdk/client-sso": "npm:3.529.1" + "@aws-sdk/token-providers": "npm:3.529.1" "@aws-sdk/types": "npm:3.523.0" "@smithy/property-provider": "npm:^2.1.3" "@smithy/shared-ini-file-loader": "npm:^2.3.3" "@smithy/types": "npm:^2.10.1" tslib: "npm:^2.5.0" - checksum: 10/0785a8c566d0d52091f56da208971496be9ac84afa5f498ee3ff83011c34847d16d9cbca74a2be37947ec7107bcced66cba5de3730d0d4d65fa964343a14d9d2 + checksum: 10/6f75e5800f6704ec5008efb2cd22ba741c27caa4d80ec069a6bb394639f8773d375f06988fb3c8f6bf54509bc78b32e910803510a06d3ba7fd671717f8221800 languageName: node linkType: hard -"@aws-sdk/credential-provider-web-identity@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/credential-provider-web-identity@npm:3.525.0" +"@aws-sdk/credential-provider-web-identity@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.529.1" dependencies: - "@aws-sdk/client-sts": "npm:3.525.0" + "@aws-sdk/client-sts": "npm:3.529.1" "@aws-sdk/types": "npm:3.523.0" "@smithy/property-provider": "npm:^2.1.3" "@smithy/types": "npm:^2.10.1" tslib: "npm:^2.5.0" - checksum: 10/94ea94f89157428cb39da7efa000fd5b65551903aa138420755f8e96a2efe4063fe0fd93befe9321525fb86d09ed0c1f98cc124110a238022535e0e97e627572 + checksum: 10/22e5b47403a51071acd91e0bf865309ad88b5ecc5df5074005783b1094207bcab4217115cb37285fb54d3461fe27a3ab272998888545ef404eafeb8a89f988e2 languageName: node linkType: hard @@ -1749,17 +1744,17 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/token-providers@npm:3.525.0": - version: 3.525.0 - resolution: "@aws-sdk/token-providers@npm:3.525.0" +"@aws-sdk/token-providers@npm:3.529.1": + version: 3.529.1 + resolution: "@aws-sdk/token-providers@npm:3.529.1" dependencies: - "@aws-sdk/client-sso-oidc": "npm:3.525.0" + "@aws-sdk/client-sso-oidc": "npm:3.529.1" "@aws-sdk/types": "npm:3.523.0" "@smithy/property-provider": "npm:^2.1.3" "@smithy/shared-ini-file-loader": "npm:^2.3.3" "@smithy/types": "npm:^2.10.1" tslib: "npm:^2.5.0" - checksum: 10/6a62ae78d506e433c5f3b378111f9df43458c5ca0c400d4d7ce0f43f3a7ad98f2ae3ae1af2fbad509c404d23a44d245fb6c42473a6d2209d0460729f2ccb93f8 + checksum: 10/03a5a4edd2fc45f078a8afeb4d0ac5f3a36a5d761f2f6636d1a3fe5027f5b34d24b0d4a72dffcd9fc44cf2b636aebae9161d7ce1165fe0937eed6ade7c4f313e languageName: node linkType: hard @@ -3359,7 +3354,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": version: 7.23.9 resolution: "@babel/runtime@npm:7.23.9" dependencies: @@ -3422,42 +3417,42 @@ __metadata: languageName: node linkType: hard -"@blocksuite/block-std@npm:0.13.0-canary-202403050653-934469c": - version: 0.13.0-canary-202403050653-934469c - resolution: "@blocksuite/block-std@npm:0.13.0-canary-202403050653-934469c" +"@blocksuite/block-std@npm:0.13.0-canary-202403140320-a2b362b": + version: 0.13.0-canary-202403140320-a2b362b + resolution: "@blocksuite/block-std@npm:0.13.0-canary-202403140320-a2b362b" dependencies: - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" lz-string: "npm:^1.5.0" w3c-keyname: "npm:^2.2.8" zod: "npm:^3.22.4" peerDependencies: - "@blocksuite/store": 0.13.0-canary-202403050653-934469c - checksum: 10/f68e4f7869d2f311a39c35f8d540beb69cbf624fac8879e16f24a9d2cc7ecf9c6f2ee555c4df2f8fb6a4eeb18db21a6a5e463fe48e3af439a5e0053d43ef0536 + "@blocksuite/store": 0.13.0-canary-202403140320-a2b362b + checksum: 10/9ac8ca2c5f7aa6e26e3d644b7501f86af8ce686666d44f0f6640d13438743fe830b0d6d69c175d0697421ad896fcf82b118818d9e37e7c351ebad19fe5d5dd26 languageName: node linkType: hard -"@blocksuite/blocks@npm:0.13.0-canary-202403050653-934469c": - version: 0.13.0-canary-202403050653-934469c - resolution: "@blocksuite/blocks@npm:0.13.0-canary-202403050653-934469c" +"@blocksuite/blocks@npm:0.13.0-canary-202403140320-a2b362b": + version: 0.13.0-canary-202403140320-a2b362b + resolution: "@blocksuite/blocks@npm:0.13.0-canary-202403140320-a2b362b" dependencies: - "@blocksuite/block-std": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/inline": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/lit": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" - "@floating-ui/dom": "npm:^1.5.4" - "@toeverything/theme": "npm:^0.7.27" - "@types/hast": "npm:^3.0.3" + "@blocksuite/block-std": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/inline": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/lit": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" + "@floating-ui/dom": "npm:^1.6.3" + "@toeverything/theme": "npm:^0.7.29" + "@types/hast": "npm:^3.0.4" "@types/mdast": "npm:^4.0.3" - "@types/sortablejs": "npm:^1.15.7" + "@types/sortablejs": "npm:^1.15.8" "@types/webfontloader": "npm:^1.6.38" buffer: "npm:^6.0.3" - date-fns: "npm:^3.3.0" + date-fns: "npm:^3.3.1" file-type: "npm:^16.5.4" fractional-indexing: "npm:^3.2.0" html2canvas: "npm:^1.4.1" jszip: "npm:^3.10.1" - lit: "npm:^3.1.1" + lit: "npm:^3.1.2" mdast-util-gfm-autolink-literal: "npm:^2.0.0" mdast-util-gfm-strikethrough: "npm:^2.0.0" mdast-util-gfm-table: "npm:^2.0.0" @@ -3469,7 +3464,7 @@ __metadata: micromark-extension-gfm-task-list-item: "npm:^2.0.1" micromark-util-combine-extensions: "npm:^2.0.0" minimatch: "npm:^9.0.3" - nanoid: "npm:^5.0.4" + nanoid: "npm:^5.0.6" pdf-lib: "npm:^1.17.1" rehype-parse: "npm:^9.0.0" rehype-stringify: "npm:^10.0.0" @@ -3480,107 +3475,107 @@ __metadata: unified: "npm:^11.0.4" webfontloader: "npm:^1.6.28" zod: "npm:^3.22.4" - checksum: 10/a0dade2e3d71199a847e0da8ca03fb19541ae29f4909fe540cf64756ada17ef2c1397d5ac743c027dac385dbd9ca445b0a6ad27112aed156fe752024b3d42608 + checksum: 10/e5d3ae180185f0973ef790df0436c69b2b5b5db44d5d2b694e6e33970f4d9e2210566983eeb492b679498e7230c041572a4d048bbe490e74de5eec8b97635445 languageName: node linkType: hard -"@blocksuite/global@npm:0.13.0-canary-202403050653-934469c": - version: 0.13.0-canary-202403050653-934469c - resolution: "@blocksuite/global@npm:0.13.0-canary-202403050653-934469c" +"@blocksuite/global@npm:0.13.0-canary-202403140320-a2b362b": + version: 0.13.0-canary-202403140320-a2b362b + resolution: "@blocksuite/global@npm:0.13.0-canary-202403140320-a2b362b" dependencies: zod: "npm:^3.22.4" - checksum: 10/c3af8171461e463120e367b74b2a60c9682bf6573bad89c73258be7d0426f158af1f83020f1018c6039fdc10c395c9872099314fb593329d118facfa25951fd6 + checksum: 10/fa2d21ee76e79d4e9a12e5cfb242ba4d0ccbe764cd2799db1b7bc65d7e1d63510fc68ff1d6772f275d4293dc8929e5693cbbdd56a016f86f47d12996de147e1a languageName: node linkType: hard -"@blocksuite/icons@npm:2.1.44": - version: 2.1.44 - resolution: "@blocksuite/icons@npm:2.1.44" +"@blocksuite/icons@npm:2.1.45": + version: 2.1.45 + resolution: "@blocksuite/icons@npm:2.1.45" peerDependencies: "@types/react": ^18.0.25 react: ^18.2.0 - checksum: 10/6d012ec2816de46ec10a17de9def5e9913a16d1df20b0f4f6730c5e79b2d1557546680054bdc63fe020728888eae68c59019ac88479ec0337e465340e4c7b6ca + checksum: 10/a77b46ab6f2325005fb873f5d1e5982bcf01588fa1c03cebaf6f4c4a1cb380dfc39c60334ceb4a7fca9486cb9e4911c15f79e54da45ed87bcea6425b00cb20e5 languageName: node linkType: hard -"@blocksuite/inline@npm:0.13.0-canary-202403050653-934469c": - version: 0.13.0-canary-202403050653-934469c - resolution: "@blocksuite/inline@npm:0.13.0-canary-202403050653-934469c" +"@blocksuite/inline@npm:0.13.0-canary-202403140320-a2b362b": + version: 0.13.0-canary-202403140320-a2b362b + resolution: "@blocksuite/inline@npm:0.13.0-canary-202403140320-a2b362b" dependencies: - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" zod: "npm:^3.22.4" peerDependencies: lit: ^3.1.1 yjs: ^13 - checksum: 10/9571c2874570a8a12cbd96ebd85be0df29a1d8492e367478b5bf2f2d4e6b1b38f3fb379a695fea3177961c15b48fa2cc5ea3a446b8b8045cebdba56fbfcf17c3 + checksum: 10/c8d489407fb65563f95221111ef8f25c7e41eabf0ef7af4d6855596af144805cc8d1dfca58e26368d908cdfffcbbc5681b7d548a3d211abcc766b144c7e40f71 languageName: node linkType: hard -"@blocksuite/lit@npm:0.13.0-canary-202403050653-934469c": - version: 0.13.0-canary-202403050653-934469c - resolution: "@blocksuite/lit@npm:0.13.0-canary-202403050653-934469c" +"@blocksuite/lit@npm:0.13.0-canary-202403140320-a2b362b": + version: 0.13.0-canary-202403140320-a2b362b + resolution: "@blocksuite/lit@npm:0.13.0-canary-202403140320-a2b362b" dependencies: - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/inline": "npm:0.13.0-canary-202403050653-934469c" - lit: "npm:^3.1.1" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/inline": "npm:0.13.0-canary-202403140320-a2b362b" + lit: "npm:^3.1.2" peerDependencies: - "@blocksuite/block-std": 0.13.0-canary-202403050653-934469c - "@blocksuite/store": 0.13.0-canary-202403050653-934469c - checksum: 10/e92fed78cd8605c6d6d5f0920d87a84752a6b1ea869d662c43161df385f26832f02b0423a9ea5d9e7e8e1a9250c1c65b456afc8c959a3a17ff732bddb0ab8967 + "@blocksuite/block-std": 0.13.0-canary-202403140320-a2b362b + "@blocksuite/store": 0.13.0-canary-202403140320-a2b362b + checksum: 10/7cbf72e453449c1231f2ec203027e558e1d4f4bfd2e889aa8358c4f4139ea99dcc9632cfad5cbd7797ad9ed1d0ddb892d3a148bcd46490853d7d409183d41e22 languageName: node linkType: hard -"@blocksuite/presets@npm:0.13.0-canary-202403050653-934469c": - version: 0.13.0-canary-202403050653-934469c - resolution: "@blocksuite/presets@npm:0.13.0-canary-202403050653-934469c" +"@blocksuite/presets@npm:0.13.0-canary-202403140320-a2b362b": + version: 0.13.0-canary-202403140320-a2b362b + resolution: "@blocksuite/presets@npm:0.13.0-canary-202403140320-a2b362b" dependencies: - "@blocksuite/block-std": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/blocks": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/inline": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/lit": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" - "@fal-ai/serverless-client": "npm:^0.8.2" - "@floating-ui/dom": "npm:^1.5.4" - "@toeverything/theme": "npm:^0.7.27" - lit: "npm:^3.1.1" - openai: "npm:^4.25.0" - checksum: 10/20cdbcb8b3c605547c5cd8e27a791e7f81ede08eacda8389dc3779fc80f8468eb8d49bd68fa689afc5e50f350bf364d8d4a0cf174aa4f78b303f53bf35b618b9 + "@blocksuite/block-std": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/blocks": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/inline": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/lit": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" + "@fal-ai/serverless-client": "npm:^0.9.0" + "@floating-ui/dom": "npm:^1.6.3" + "@toeverything/theme": "npm:^0.7.29" + lit: "npm:^3.1.2" + openai: "npm:^4.28.4" + checksum: 10/17c0bdb82ac9a30009a1a9590466aad792dc19223ea1052757817afbe1f2dcb5010cb18adeecfad0b4086c6cd0f1aed84ee6be5267f54c0e0999f3c2362f8fb2 languageName: node linkType: hard -"@blocksuite/store@npm:0.13.0-canary-202403050653-934469c": - version: 0.13.0-canary-202403050653-934469c - resolution: "@blocksuite/store@npm:0.13.0-canary-202403050653-934469c" +"@blocksuite/store@npm:0.13.0-canary-202403140320-a2b362b": + version: 0.13.0-canary-202403140320-a2b362b + resolution: "@blocksuite/store@npm:0.13.0-canary-202403140320-a2b362b" dependencies: - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/inline": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/sync": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/inline": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/sync": "npm:0.13.0-canary-202403140320-a2b362b" "@types/flexsearch": "npm:^0.7.6" flexsearch: "npm:0.7.43" idb-keyval: "npm:^6.2.1" - lib0: "npm:^0.2.88" + lib0: "npm:^0.2.91" merge: "npm:^2.1.1" minimatch: "npm:^9.0.3" - nanoid: "npm:^5.0.4" + nanoid: "npm:^5.0.6" y-protocols: "npm:^1.0.6" zod: "npm:^3.22.4" peerDependencies: yjs: ^13 - checksum: 10/e1d9837f537bfac7fc89c00f212883d99eb906af3fe514e339f6140c1754d5a7fcef5cc12e036f1b9b6074fd2f79cf2c96af384864a02835228a975968e8a113 + checksum: 10/b8d6ca0fad7d861c0e6d457baf0a0ea370ea28f178df837f6a699cc3803f6750f6c9ad0bcbca162860c0add8801bf3c45bcddbedbdb87066a75a6b05a2570f51 languageName: node linkType: hard -"@blocksuite/sync@npm:0.13.0-canary-202403050653-934469c": - version: 0.13.0-canary-202403050653-934469c - resolution: "@blocksuite/sync@npm:0.13.0-canary-202403050653-934469c" +"@blocksuite/sync@npm:0.13.0-canary-202403140320-a2b362b": + version: 0.13.0-canary-202403140320-a2b362b + resolution: "@blocksuite/sync@npm:0.13.0-canary-202403140320-a2b362b" dependencies: - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" idb: "npm:^8.0.0" y-protocols: "npm:^1.0.6" peerDependencies: yjs: ^13 - checksum: 10/b957d7d318494e35f5b2781459dda37c90f374f560afbeea1fe73a3f6810ac005b4c8f49c3004b3f5bd07b2acf501c6be9964eec191104218f6e331cb6daaf5b + checksum: 10/5a1323844c466b6f5bdbb5d710554f40ba25b2f5dc068110b6e9bf112d772b13da8449dcceefdb3f60105731df2ef78b219e0d4a3d8078446c9ac510eebbc71f languageName: node linkType: hard @@ -5268,14 +5263,15 @@ __metadata: languageName: node linkType: hard -"@fal-ai/serverless-client@npm:^0.8.2": - version: 0.8.2 - resolution: "@fal-ai/serverless-client@npm:0.8.2" +"@fal-ai/serverless-client@npm:^0.9.0": + version: 0.9.0 + resolution: "@fal-ai/serverless-client@npm:0.9.0" dependencies: "@msgpack/msgpack": "npm:^3.0.0-beta2" + eventsource-parser: "npm:^1.1.2" robot3: "npm:^0.4.1" uuid-random: "npm:^1.3.2" - checksum: 10/296b2cb406837c0dbb28b0f3361e0a43bb7a14496016b220984f302aebc6dec004a6a0df7067eff2ac583e3334f17e91fec7b46ea397386782dd1b93d2e4be7c + checksum: 10/88ab594d88b780eca1ee33af5e92eceb98b96884d0881b9ff83cf3db6cec42c40dea1154f055becfcfc45c7e154e2c5a9d489db50649b658f2eb2b7e28beab91 languageName: node linkType: hard @@ -5304,7 +5300,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/core@npm:^1.6.0": +"@floating-ui/core@npm:^1.0.0": version: 1.6.0 resolution: "@floating-ui/core@npm:1.6.0" dependencies: @@ -5313,13 +5309,13 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:^1.2.1, @floating-ui/dom@npm:^1.5.4, @floating-ui/dom@npm:^1.6.1": - version: 1.6.1 - resolution: "@floating-ui/dom@npm:1.6.1" +"@floating-ui/dom@npm:^1.2.1, @floating-ui/dom@npm:^1.6.1, @floating-ui/dom@npm:^1.6.3": + version: 1.6.3 + resolution: "@floating-ui/dom@npm:1.6.3" dependencies: - "@floating-ui/core": "npm:^1.6.0" - "@floating-ui/utils": "npm:^0.2.1" - checksum: 10/c010feb55be37662eb4cc8d0a22e21359c25247bbdcd9557617fd305cf08c8f020435b17e4b4f410201ba9abe3a0dd96b5c42d56e85f7a5e11e7d30b85afc116 + "@floating-ui/core": "npm:^1.0.0" + "@floating-ui/utils": "npm:^0.2.0" + checksum: 10/83e97076c7a5f55c3506f574bc53f03d38bed6eb8181920c8733076889371e287e9ae6f28c520a076967759b9b6ff425362832a5cdf16a999069530dbb9cce53 languageName: node linkType: hard @@ -5361,7 +5357,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/utils@npm:^0.2.1": +"@floating-ui/utils@npm:^0.2.0, @floating-ui/utils@npm:^0.2.1": version: 0.2.1 resolution: "@floating-ui/utils@npm:0.2.1" checksum: 10/33c9ab346e7b05c5a1e6a95bc902aafcfc2c9d513a147e2491468843bd5607531b06d0b9aa56aa491cbf22a6c2495c18ccfc4c0344baec54a689a7bb8e4898d6 @@ -8483,21 +8479,21 @@ __metadata: languageName: node linkType: hard -"@nrwl/devkit@npm:18.0.7": - version: 18.0.7 - resolution: "@nrwl/devkit@npm:18.0.7" +"@nrwl/devkit@npm:18.0.8": + version: 18.0.8 + resolution: "@nrwl/devkit@npm:18.0.8" dependencies: - "@nx/devkit": "npm:18.0.7" - checksum: 10/0de58007685f8e10eb1b72b2ffde9a31c1b161c24cfc3121386c0a4396fb5855bb07a5cf086f78e074fa055e8ecfa7836ce54f433e8d1ff64d67a58b281da25f + "@nx/devkit": "npm:18.0.8" + checksum: 10/ec59fabf272a84a5062b37af7940b682d398272531020dd32622005c99898e087e7422e198251c0df28b1d00d10215521771f1a6f09f6aaa5f236f8348bb7446 languageName: node linkType: hard -"@nrwl/js@npm:18.0.7": - version: 18.0.7 - resolution: "@nrwl/js@npm:18.0.7" +"@nrwl/js@npm:18.0.8": + version: 18.0.8 + resolution: "@nrwl/js@npm:18.0.8" dependencies: - "@nx/js": "npm:18.0.7" - checksum: 10/6084c9097b88cb5fce4e8683be5f851a5e771deee160684c59cbf0a1ec967220b60a58e8c29d4ceaaf864534e5369da3b7ce668982b2557d8af4233e49c2f494 + "@nx/js": "npm:18.0.8" + checksum: 10/2f21251d34252a02f626e26ddefd8e8fe3603614848599f76253a28cc0a1d0c289d8352f68ba46c438ce2f1577d7194b0fc8e72937b331cc5b2b0a9f06d01aca languageName: node linkType: hard @@ -8510,33 +8506,33 @@ __metadata: languageName: node linkType: hard -"@nrwl/tao@npm:18.0.7": - version: 18.0.7 - resolution: "@nrwl/tao@npm:18.0.7" +"@nrwl/tao@npm:18.0.8": + version: 18.0.8 + resolution: "@nrwl/tao@npm:18.0.8" dependencies: - nx: "npm:18.0.7" + nx: "npm:18.0.8" tslib: "npm:^2.3.0" bin: tao: index.js - checksum: 10/9b0498f32c3542a747d6a3f39eb886d382ddf78c9f2ce398f88b05f6b7899f283d84107cd0f61ec26a59e3f55086683b79f2eecd500614e242fb702db91df25c + checksum: 10/136b855a496cbaa857a2341048178ef5e54f26a9de9edb314a8c7f6ca799c4c56d3eea83d72a94ae38c374b7cd0bec0f3745d67d05ffed36b43a1df5b4b54e45 languageName: node linkType: hard -"@nrwl/vite@npm:18.0.7": - version: 18.0.7 - resolution: "@nrwl/vite@npm:18.0.7" +"@nrwl/vite@npm:18.0.8": + version: 18.0.8 + resolution: "@nrwl/vite@npm:18.0.8" dependencies: - "@nx/vite": "npm:18.0.7" - checksum: 10/69e9957a20612e8468cb40ad61286fd69ce5cae64f0f158eb82d03a5e46ce97f93d8598e2ce31201f9207c316a1fc3582400729e4a6a720da69a8e7431dad89a + "@nx/vite": "npm:18.0.8" + checksum: 10/263c68c648a36c3053dc59a0eae0edaa1f9cad1c080982c8eb2cda7902e83976ec3d5fd21884cc1409ce2a207ec03e5d981f0e6e11fea18fc8cd55298587d26c languageName: node linkType: hard -"@nrwl/workspace@npm:18.0.7": - version: 18.0.7 - resolution: "@nrwl/workspace@npm:18.0.7" +"@nrwl/workspace@npm:18.0.8": + version: 18.0.8 + resolution: "@nrwl/workspace@npm:18.0.8" dependencies: - "@nx/workspace": "npm:18.0.7" - checksum: 10/c51e04d140345611d123a1936b86dc0c11d2890727281bd2166893d2a9ef81bb03871d7f17a4a70a20abc1d2f81fc18cd419ee49e8a6deb42665bfbca4c34b53 + "@nx/workspace": "npm:18.0.8" + checksum: 10/fcb4acb6ca81844e2ee81ae18dc3cad570ebbbca4e8fa32030a9f61c5b5bf0e114823418740d7f169d9233f36ab1001d42218f0bbaec6ab246ceeb9a05b8c0ce languageName: node linkType: hard @@ -8553,11 +8549,11 @@ __metadata: languageName: node linkType: hard -"@nx/devkit@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/devkit@npm:18.0.7" +"@nx/devkit@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/devkit@npm:18.0.8" dependencies: - "@nrwl/devkit": "npm:18.0.7" + "@nrwl/devkit": "npm:18.0.8" ejs: "npm:^3.1.7" enquirer: "npm:~2.3.6" ignore: "npm:^5.0.4" @@ -8567,13 +8563,13 @@ __metadata: yargs-parser: "npm:21.1.1" peerDependencies: nx: ">= 16 <= 18" - checksum: 10/67a8d92e4c60d7caa4cd306fd8ce19817f0b366c9506fa244f019b42aa5fc333eaeffff8eca18f834e1bad6f3b23b339cbf5c85b45f56fe22d09b1c77cbc134e + checksum: 10/2c2ad2febfc4016d743d43ee0f58f0c6af82bde950c0bc94f1258c7b990bca9933db914aa281717fd4939009215736ee3b9694780ce50fd876987dcfd8e0e083 languageName: node linkType: hard -"@nx/js@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/js@npm:18.0.7" +"@nx/js@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/js@npm:18.0.8" dependencies: "@babel/core": "npm:^7.23.2" "@babel/plugin-proposal-decorators": "npm:^7.22.7" @@ -8582,9 +8578,9 @@ __metadata: "@babel/preset-env": "npm:^7.23.2" "@babel/preset-typescript": "npm:^7.22.5" "@babel/runtime": "npm:^7.22.6" - "@nrwl/js": "npm:18.0.7" - "@nx/devkit": "npm:18.0.7" - "@nx/workspace": "npm:18.0.7" + "@nrwl/js": "npm:18.0.8" + "@nx/devkit": "npm:18.0.8" + "@nx/workspace": "npm:18.0.8" "@phenomnomnominal/tsquery": "npm:~5.0.1" babel-plugin-const-enum: "npm:^1.0.1" babel-plugin-macros: "npm:^2.8.0" @@ -8610,87 +8606,87 @@ __metadata: peerDependenciesMeta: verdaccio: optional: true - checksum: 10/7233412d03bd68c94d83ad9e0516ac4bc5342669a63fbd23be415794dc52cd7c4ea81862a12cf3e16fc48bfde84e1bafdfc4fa8c2815d453f64a26e14c29b831 + checksum: 10/93fa4ca956c6f266a16723e25cad63524638c82993d6ab5e6a972e0f61dd85b318a49b7e04b126544cd870196b36559b4a32b984aa962038369eeabe219d9e52 languageName: node linkType: hard -"@nx/nx-darwin-arm64@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-darwin-arm64@npm:18.0.7" +"@nx/nx-darwin-arm64@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-darwin-arm64@npm:18.0.8" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@nx/nx-darwin-x64@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-darwin-x64@npm:18.0.7" +"@nx/nx-darwin-x64@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-darwin-x64@npm:18.0.8" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@nx/nx-freebsd-x64@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-freebsd-x64@npm:18.0.7" +"@nx/nx-freebsd-x64@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-freebsd-x64@npm:18.0.8" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@nx/nx-linux-arm-gnueabihf@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-linux-arm-gnueabihf@npm:18.0.7" +"@nx/nx-linux-arm-gnueabihf@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-linux-arm-gnueabihf@npm:18.0.8" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@nx/nx-linux-arm64-gnu@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-linux-arm64-gnu@npm:18.0.7" +"@nx/nx-linux-arm64-gnu@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-linux-arm64-gnu@npm:18.0.8" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@nx/nx-linux-arm64-musl@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-linux-arm64-musl@npm:18.0.7" +"@nx/nx-linux-arm64-musl@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-linux-arm64-musl@npm:18.0.8" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@nx/nx-linux-x64-gnu@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-linux-x64-gnu@npm:18.0.7" +"@nx/nx-linux-x64-gnu@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-linux-x64-gnu@npm:18.0.8" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@nx/nx-linux-x64-musl@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-linux-x64-musl@npm:18.0.7" +"@nx/nx-linux-x64-musl@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-linux-x64-musl@npm:18.0.8" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@nx/nx-win32-arm64-msvc@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-win32-arm64-msvc@npm:18.0.7" +"@nx/nx-win32-arm64-msvc@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-win32-arm64-msvc@npm:18.0.8" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@nx/nx-win32-x64-msvc@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/nx-win32-x64-msvc@npm:18.0.7" +"@nx/nx-win32-x64-msvc@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/nx-win32-x64-msvc@npm:18.0.8" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@nx/vite@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/vite@npm:18.0.7" +"@nx/vite@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/vite@npm:18.0.8" dependencies: - "@nrwl/vite": "npm:18.0.7" - "@nx/devkit": "npm:18.0.7" - "@nx/js": "npm:18.0.7" + "@nrwl/vite": "npm:18.0.8" + "@nx/devkit": "npm:18.0.8" + "@nx/js": "npm:18.0.8" "@phenomnomnominal/tsquery": "npm:~5.0.1" "@swc/helpers": "npm:~0.5.0" enquirer: "npm:~2.3.6" @@ -8698,22 +8694,22 @@ __metadata: peerDependencies: vite: ^5.0.0 vitest: ^1.3.1 - checksum: 10/f3b35cb9b8d1f9b6dee880a495f6a196886eb4b242c041622f4d47bda7be3c1bb3c8a39d880230bfe8609dd70fbbc20f9f97d90e74b9ee28b6845f89d65cd6ad + checksum: 10/6e798914c1274d21831d1b80648785e9a97d79f2258c9054d3eba3719a133c106848eb1fad4d5da8ae0bfc43da322d326d52d1e0e6f3c4b0ff421e9d8098a728 languageName: node linkType: hard -"@nx/workspace@npm:18.0.7": - version: 18.0.7 - resolution: "@nx/workspace@npm:18.0.7" +"@nx/workspace@npm:18.0.8": + version: 18.0.8 + resolution: "@nx/workspace@npm:18.0.8" dependencies: - "@nrwl/workspace": "npm:18.0.7" - "@nx/devkit": "npm:18.0.7" + "@nrwl/workspace": "npm:18.0.8" + "@nx/devkit": "npm:18.0.8" chalk: "npm:^4.1.0" enquirer: "npm:~2.3.6" - nx: "npm:18.0.7" + nx: "npm:18.0.8" tslib: "npm:^2.3.0" yargs-parser: "npm:21.1.1" - checksum: 10/91e17dc167f4aa96cdb164a2cc6a768b4f48c0a7841dffdedfef4ef9f2d679b14719a77a5ee5fe2424a585b7271875436138d6bf4899f25c7b868b81a3a6659b + checksum: 10/452974dfcb2f74f22455e8e2b0c8f111e46fd94118dec4fd787a5e0072c2bba1d65ee750e8ddb0bac53d1c5a969bf199d7253574d43ede9081dfd4c76d975d8b languageName: node linkType: hard @@ -8999,14 +8995,14 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/instrumentation-graphql@npm:^0.37.0": - version: 0.37.0 - resolution: "@opentelemetry/instrumentation-graphql@npm:0.37.0" +"@opentelemetry/instrumentation-graphql@npm:^0.38.0": + version: 0.38.0 + resolution: "@opentelemetry/instrumentation-graphql@npm:0.38.0" dependencies: - "@opentelemetry/instrumentation": "npm:^0.48.0" + "@opentelemetry/instrumentation": "npm:^0.49.1" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/2c6ad78f23ebedc09ee6b5ce3372d7387e4c40907a481d2268eaedf98d50c11fa9db9480c7d022f91cd5e37d644beb32caed6aefd4513d42deb6ec00da259ba7 + checksum: 10/610ad2791edc234c2b7ad9cb38249c906cb0b3ea809e4cd4b2176c2a379262b44b52881560c491457f243e48258d49c2899ce133cf46f40c994063e8776e8e92 languageName: node linkType: hard @@ -9024,45 +9020,45 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/instrumentation-ioredis@npm:^0.37.0": - version: 0.37.0 - resolution: "@opentelemetry/instrumentation-ioredis@npm:0.37.0" +"@opentelemetry/instrumentation-ioredis@npm:^0.38.0": + version: 0.38.0 + resolution: "@opentelemetry/instrumentation-ioredis@npm:0.38.0" dependencies: - "@opentelemetry/instrumentation": "npm:^0.48.0" + "@opentelemetry/instrumentation": "npm:^0.49.1" "@opentelemetry/redis-common": "npm:^0.36.1" "@opentelemetry/semantic-conventions": "npm:^1.0.0" "@types/ioredis4": "npm:@types/ioredis@^4.28.10" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/ec26360d1f7e0ad091c9008bb877852b795e2b881d62078e48d6e193bb6b12641995f95c52eba42877e87ea3642929d46e0733919cf2a5fa0bb91790167a6f1a + checksum: 10/2825a92c6fde895967fb55ca9631fea3b3507acee366ccf0b9af692106f35a258d124aa16e435b10dcbedf78876e6469b8531419099446d937f2fb89894674ac languageName: node linkType: hard -"@opentelemetry/instrumentation-nestjs-core@npm:^0.34.0": - version: 0.34.0 - resolution: "@opentelemetry/instrumentation-nestjs-core@npm:0.34.0" +"@opentelemetry/instrumentation-nestjs-core@npm:^0.35.0": + version: 0.35.0 + resolution: "@opentelemetry/instrumentation-nestjs-core@npm:0.35.0" dependencies: - "@opentelemetry/instrumentation": "npm:^0.48.0" + "@opentelemetry/instrumentation": "npm:^0.49.1" "@opentelemetry/semantic-conventions": "npm:^1.0.0" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/f01499acd3b0ea24e9d52319012605c16f925b7c8e880428ae67b596c378deffd289b143aedc3f9bd731a299fe728b3f38315da61421fd45c5928a52b217bba9 + checksum: 10/aa7fd8af2f5ed1dfa37933ffb735558c5e3c834a8b29521d12309e2b25256280f17a85759bf32235f4b197e010491eae02a8a3d73492c6d33a4f63f370b47896 languageName: node linkType: hard -"@opentelemetry/instrumentation-socket.io@npm:^0.36.0": - version: 0.36.0 - resolution: "@opentelemetry/instrumentation-socket.io@npm:0.36.0" +"@opentelemetry/instrumentation-socket.io@npm:^0.37.0": + version: 0.37.0 + resolution: "@opentelemetry/instrumentation-socket.io@npm:0.37.0" dependencies: - "@opentelemetry/instrumentation": "npm:^0.48.0" + "@opentelemetry/instrumentation": "npm:^0.49.1" "@opentelemetry/semantic-conventions": "npm:^1.0.0" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/e82c01635f6319a65fb86dc766c9fb6d1fb861c51d754baa5d28e8577de560ba512655d829dc5da8ace6a4d27cac2616cbde7ec74bfbfa3450928d430b527d49 + checksum: 10/7376bde6a661a477e52da3d61c1b0622b72c5e19140cac2cc2cdc2d52b1e2def93ec305963f4e0cc890ac214cf0a690ebb6db9088ece6a73275343f6a4df2243 languageName: node linkType: hard -"@opentelemetry/instrumentation@npm:0.48.0, @opentelemetry/instrumentation@npm:^0.48.0": +"@opentelemetry/instrumentation@npm:0.48.0": version: 0.48.0 resolution: "@opentelemetry/instrumentation@npm:0.48.0" dependencies: @@ -9077,7 +9073,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/instrumentation@npm:0.49.1, @opentelemetry/instrumentation@npm:^0.49.0": +"@opentelemetry/instrumentation@npm:0.49.1, @opentelemetry/instrumentation@npm:^0.49.0, @opentelemetry/instrumentation@npm:^0.49.1": version: 0.49.1 resolution: "@opentelemetry/instrumentation@npm:0.49.1" dependencies: @@ -9347,7 +9343,7 @@ __metadata: languageName: node linkType: hard -"@panva/hkdf@npm:^1.0.2, @panva/hkdf@npm:^1.1.1": +"@panva/hkdf@npm:^1.1.1": version: 1.1.1 resolution: "@panva/hkdf@npm:1.1.1" checksum: 10/f0dd12903751d8792420353f809ed3c7de860cf506399759fff5f59f7acfef8a77e2b64012898cee7e5b047708fa0bd91dff5ef55a502bf8ea11aad9842160da @@ -12356,6 +12352,19 @@ __metadata: languageName: node linkType: hard +"@storybook/channels@npm:8.0.0": + version: 8.0.0 + resolution: "@storybook/channels@npm:8.0.0" + dependencies: + "@storybook/client-logger": "npm:8.0.0" + "@storybook/core-events": "npm:8.0.0" + "@storybook/global": "npm:^5.0.0" + telejson: "npm:^7.2.0" + tiny-invariant: "npm:^1.3.1" + checksum: 10/8dee7c2fb193af18da52c0b66b75b8ec72290a8001166070453b37072f7ea430a090ee8a02929decb23b7a0df3f28b236ff4711f6b5e933ba1adab8f0d2b7c44 + languageName: node + linkType: hard + "@storybook/cli@npm:7.6.17": version: 7.6.17 resolution: "@storybook/cli@npm:7.6.17" @@ -12425,6 +12434,15 @@ __metadata: languageName: node linkType: hard +"@storybook/client-logger@npm:8.0.0": + version: 8.0.0 + resolution: "@storybook/client-logger@npm:8.0.0" + dependencies: + "@storybook/global": "npm:^5.0.0" + checksum: 10/74b10807f806e3f0d25eb6059a7acff76c8712105dbfa5ad071a7ccd8d2b1824e0dfbe990ae446baf69a9745e12a3ff82b36682c515da84edc6f1d93bab4bb70 + languageName: node + linkType: hard + "@storybook/codemod@npm:7.6.17": version: 7.6.17 resolution: "@storybook/codemod@npm:7.6.17" @@ -12478,7 +12496,7 @@ __metadata: languageName: node linkType: hard -"@storybook/core-common@npm:7.6.17, @storybook/core-common@npm:^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0": +"@storybook/core-common@npm:7.6.17": version: 7.6.17 resolution: "@storybook/core-common@npm:7.6.17" dependencies: @@ -12509,6 +12527,42 @@ __metadata: languageName: node linkType: hard +"@storybook/core-common@npm:^8.0.0": + version: 8.0.0 + resolution: "@storybook/core-common@npm:8.0.0" + dependencies: + "@storybook/core-events": "npm:8.0.0" + "@storybook/csf-tools": "npm:8.0.0" + "@storybook/node-logger": "npm:8.0.0" + "@storybook/types": "npm:8.0.0" + "@yarnpkg/fslib": "npm:2.10.3" + "@yarnpkg/libzip": "npm:2.3.0" + chalk: "npm:^4.1.0" + cross-spawn: "npm:^7.0.3" + esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0" + esbuild-register: "npm:^3.5.0" + execa: "npm:^5.0.0" + file-system-cache: "npm:2.3.0" + find-cache-dir: "npm:^3.0.0" + find-up: "npm:^5.0.0" + fs-extra: "npm:^11.1.0" + glob: "npm:^10.0.0" + handlebars: "npm:^4.7.7" + lazy-universal-dotenv: "npm:^4.0.0" + node-fetch: "npm:^2.0.0" + picomatch: "npm:^2.3.0" + pkg-dir: "npm:^5.0.0" + pretty-hrtime: "npm:^1.0.3" + resolve-from: "npm:^5.0.0" + semver: "npm:^7.3.7" + tempy: "npm:^1.0.1" + tiny-invariant: "npm:^1.3.1" + ts-dedent: "npm:^2.0.0" + util: "npm:^0.12.4" + checksum: 10/4a1e4def30fd85f7cad93562843512c9cdf2c8e9a7bceb874ab72b2a2d3c1e567b53bc4a8656566707ae26fd718db5ac933b8bfcaff63e2dffbacb545d3832c5 + languageName: node + linkType: hard + "@storybook/core-events@npm:7.5.3": version: 7.5.3 resolution: "@storybook/core-events@npm:7.5.3" @@ -12527,6 +12581,15 @@ __metadata: languageName: node linkType: hard +"@storybook/core-events@npm:8.0.0": + version: 8.0.0 + resolution: "@storybook/core-events@npm:8.0.0" + dependencies: + ts-dedent: "npm:^2.0.0" + checksum: 10/61287f661e7042b2e6b5baad4fc513c889ddfecc6cd14e3ad5e9f953d19728db81435fba0db12d31b4853031cf9a439102893ff03faf72173ebcfb612d8713ab + languageName: node + linkType: hard + "@storybook/core-server@npm:7.6.17": version: 7.6.17 resolution: "@storybook/core-server@npm:7.6.17" @@ -12586,7 +12649,7 @@ __metadata: languageName: node linkType: hard -"@storybook/csf-tools@npm:7.6.17, @storybook/csf-tools@npm:^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0": +"@storybook/csf-tools@npm:7.6.17": version: 7.6.17 resolution: "@storybook/csf-tools@npm:7.6.17" dependencies: @@ -12603,7 +12666,24 @@ __metadata: languageName: node linkType: hard -"@storybook/csf@npm:^0.1.0, @storybook/csf@npm:^0.1.1, @storybook/csf@npm:^0.1.2": +"@storybook/csf-tools@npm:8.0.0, @storybook/csf-tools@npm:^8.0.0": + version: 8.0.0 + resolution: "@storybook/csf-tools@npm:8.0.0" + dependencies: + "@babel/generator": "npm:^7.23.0" + "@babel/parser": "npm:^7.23.0" + "@babel/traverse": "npm:^7.23.2" + "@babel/types": "npm:^7.23.0" + "@storybook/csf": "npm:^0.1.2" + "@storybook/types": "npm:8.0.0" + fs-extra: "npm:^11.1.0" + recast: "npm:^0.23.5" + ts-dedent: "npm:^2.0.0" + checksum: 10/785cbfc16af44ff3ab35d7effceeb61c7e971d5527fc0e1a573a96ec622726a8ebc0279de6a4691292356610c48913d9acf6a66734603b03e6a25192a6349e24 + languageName: node + linkType: hard + +"@storybook/csf@npm:^0.1.0, @storybook/csf@npm:^0.1.2": version: 0.1.2 resolution: "@storybook/csf@npm:0.1.2" dependencies: @@ -12731,6 +12811,13 @@ __metadata: languageName: node linkType: hard +"@storybook/node-logger@npm:8.0.0": + version: 8.0.0 + resolution: "@storybook/node-logger@npm:8.0.0" + checksum: 10/39cdc20133f39a354c1801a4705186618f0f7601dedd2dacc3d5a27c0e434f26f7f09213b1dbb914f543f02cb16287b2cc99e56faa136e3e629404ff76645d4f + languageName: node + linkType: hard + "@storybook/postinstall@npm:7.6.17": version: 7.6.17 resolution: "@storybook/postinstall@npm:7.6.17" @@ -12760,7 +12847,7 @@ __metadata: languageName: node linkType: hard -"@storybook/preview-api@npm:7.6.17, @storybook/preview-api@npm:^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0": +"@storybook/preview-api@npm:7.6.17": version: 7.6.17 resolution: "@storybook/preview-api@npm:7.6.17" dependencies: @@ -12782,6 +12869,28 @@ __metadata: languageName: node linkType: hard +"@storybook/preview-api@npm:^8.0.0": + version: 8.0.0 + resolution: "@storybook/preview-api@npm:8.0.0" + dependencies: + "@storybook/channels": "npm:8.0.0" + "@storybook/client-logger": "npm:8.0.0" + "@storybook/core-events": "npm:8.0.0" + "@storybook/csf": "npm:^0.1.2" + "@storybook/global": "npm:^5.0.0" + "@storybook/types": "npm:8.0.0" + "@types/qs": "npm:^6.9.5" + dequal: "npm:^2.0.2" + lodash: "npm:^4.17.21" + memoizerific: "npm:^1.11.3" + qs: "npm:^6.10.0" + tiny-invariant: "npm:^1.3.1" + ts-dedent: "npm:^2.0.0" + util-deprecate: "npm:^1.0.2" + checksum: 10/9cc83e485d11a90778174d94f71dccc6652ed2b2a1053dbaac2effa814d596a8467f94d21181ca135ec416da9720b8d25f3fbe57f5b4a17dc38818d268577f5d + languageName: node + linkType: hard + "@storybook/preview@npm:7.6.17": version: 7.6.17 resolution: "@storybook/preview@npm:7.6.17" @@ -12908,25 +13017,22 @@ __metadata: languageName: node linkType: hard -"@storybook/test-runner@npm:^0.16.0": - version: 0.16.0 - resolution: "@storybook/test-runner@npm:0.16.0" +"@storybook/test-runner@npm:^0.17.0": + version: 0.17.0 + resolution: "@storybook/test-runner@npm:0.17.0" dependencies: "@babel/core": "npm:^7.22.5" "@babel/generator": "npm:^7.22.5" "@babel/template": "npm:^7.22.5" "@babel/types": "npm:^7.22.5" "@jest/types": "npm:^29.6.3" - "@storybook/core-common": "npm:^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0" - "@storybook/csf": "npm:^0.1.1" - "@storybook/csf-tools": "npm:^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0" - "@storybook/preview-api": "npm:^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0" + "@storybook/core-common": "npm:^8.0.0" + "@storybook/csf": "npm:^0.1.2" + "@storybook/csf-tools": "npm:^8.0.0" + "@storybook/preview-api": "npm:^8.0.0" "@swc/core": "npm:^1.3.18" "@swc/jest": "npm:^0.2.23" - can-bind-to-host: "npm:^1.1.1" - commander: "npm:^9.0.0" expect-playwright: "npm:^0.8.0" - glob: "npm:^10.2.2" jest: "npm:^29.6.4" jest-circus: "npm:^29.6.4" jest-environment-node: "npm:^29.6.4" @@ -12935,14 +13041,10 @@ __metadata: jest-runner: "npm:^29.6.4" jest-serializer-html: "npm:^7.1.0" jest-watch-typeahead: "npm:^2.0.0" - node-fetch: "npm:^2" playwright: "npm:^1.14.0" - read-pkg-up: "npm:^7.0.1" - tempy: "npm:^1.0.1" - ts-dedent: "npm:^2.0.0" bin: test-storybook: dist/test-storybook.js - checksum: 10/8e73882d3484899e4b964f4cd48afeb5aadd28b8f67f03c0c494294c84ae55dd1f7c0bc164a7ad3478b88e78eff74d4ac4183e603fdba6ddc1e9df2215887b6f + checksum: 10/c3344041e94f26f7fc2061d527fc77def63fbb0b151e0249ec6af3010d87a483f269178f1eab41525d18ab0f599a024530824a99d4439ca493e09d7ef60ef1e2 languageName: node linkType: hard @@ -13011,6 +13113,17 @@ __metadata: languageName: node linkType: hard +"@storybook/types@npm:8.0.0": + version: 8.0.0 + resolution: "@storybook/types@npm:8.0.0" + dependencies: + "@storybook/channels": "npm:8.0.0" + "@types/express": "npm:^4.7.0" + file-system-cache: "npm:2.3.0" + checksum: 10/ef3f01cffba88c1fd4ef8dafe228de76c746002bb17f2566a80481acce9ca129b45d72fa05c9fc987356e2dfbd945a404fedfeca383f52bffaff455853f3cea8 + languageName: node + linkType: hard + "@swc/core-darwin-arm64@npm:1.4.2": version: 1.4.2 resolution: "@swc/core-darwin-arm64@npm:1.4.2" @@ -13257,11 +13370,11 @@ __metadata: "@affine/debug": "workspace:*" "@affine/env": "workspace:*" "@affine/templates": "workspace:*" - "@blocksuite/blocks": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/lit": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/presets": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/blocks": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/lit": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/presets": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" "@testing-library/react": "npm:^14.2.1" async-call-rpc: "npm:^6.4.0" foxact: "npm:^0.2.31" @@ -13300,7 +13413,7 @@ __metadata: languageName: unknown linkType: soft -"@toeverything/theme@npm:^0.7.27, @toeverything/theme@npm:^0.7.29": +"@toeverything/theme@npm:^0.7.29": version: 0.7.29 resolution: "@toeverything/theme@npm:0.7.29" checksum: 10/d1dde76147bc9be8d6cc5d340cb24ceba90668e3b54ee36fc68d4b50aae8a8cb39a47eb193087c07491e5b3c0c5bc6b3042abd5ba490709406b2096163b6e1a5 @@ -13311,9 +13424,9 @@ __metadata: version: 0.0.0-use.local resolution: "@toeverything/y-indexeddb@workspace:packages/common/y-indexeddb" dependencies: - "@blocksuite/blocks": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/global": "npm:0.13.0-canary-202403050653-934469c" - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/blocks": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/global": "npm:0.13.0-canary-202403140320-a2b362b" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" fake-indexeddb: "npm:^5.0.2" idb: "npm:^8.0.0" nanoid: "npm:^5.0.6" @@ -13335,16 +13448,6 @@ __metadata: languageName: node linkType: hard -"@tomfreudenberg/next-auth-mock@npm:^0.5.6": - version: 0.5.6 - resolution: "@tomfreudenberg/next-auth-mock@npm:0.5.6" - peerDependencies: - next-auth: ^4.12.3 - react: ^18 - checksum: 10/50396706be6f3e806d130df3945dce4233504782f0f16fd6d255d54ef21ae713b9eedf3a93155de29f92f59d3592bd540a60a15edfffa4b6306c7a3c786aaae2 - languageName: node - linkType: hard - "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -13798,12 +13901,12 @@ __metadata: languageName: node linkType: hard -"@types/hast@npm:^3.0.0, @types/hast@npm:^3.0.3": - version: 3.0.3 - resolution: "@types/hast@npm:3.0.3" +"@types/hast@npm:^3.0.0, @types/hast@npm:^3.0.4": + version: 3.0.4 + resolution: "@types/hast@npm:3.0.4" dependencies: "@types/unist": "npm:*" - checksum: 10/cf380cb351215847a598b06c10c0139a694fbb9a5cca27e7a836df4c9d616873ff5b01326530907b5a95b6a4b8fc928bcecb46424cc6f9bd1f53ba377f190d86 + checksum: 10/732920d81bb7605895776841b7658b4d8cc74a43a8fa176017cc0fb0ecc1a4c82a2b75a4fe6b71aa262b649d3fb62858c6789efa3793ea1d40269953af96ecb5 languageName: node linkType: hard @@ -14338,7 +14441,7 @@ __metadata: languageName: node linkType: hard -"@types/sortablejs@npm:^1.15.7": +"@types/sortablejs@npm:^1.15.8": version: 1.15.8 resolution: "@types/sortablejs@npm:1.15.8" checksum: 10/aea58b08cf45f5e9633707a8df0df1212595c731bbdfd29805487138fdd0d8c51fa5c741999738a645c1e801d43a92ba0d3fb5b45625b52e247c56588aef6c55 @@ -15928,7 +16031,7 @@ __metadata: languageName: node linkType: hard -"assert@npm:^2.0.0, assert@npm:^2.1.0": +"assert@npm:^2.1.0": version: 2.1.0 resolution: "assert@npm:2.1.0" dependencies: @@ -16975,15 +17078,6 @@ __metadata: languageName: node linkType: hard -"can-bind-to-host@npm:^1.1.1": - version: 1.1.2 - resolution: "can-bind-to-host@npm:1.1.2" - bin: - can-bind-to-host: dist/bin/can-bind-to-host.js - checksum: 10/d2ad6a0719d1d0013df9b70e362da2f3187c86bcbb2987d1d821c381bb9dfe5483ac63ce7f6dd99489c06854c12c5877a279687a09e9909985a802e77ee9c7ab - languageName: node - linkType: hard - "caniuse-api@npm:^3.0.0": version: 3.0.0 resolution: "caniuse-api@npm:3.0.0" @@ -17805,7 +17899,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^9.0.0, commander@npm:^9.4.0, commander@npm:^9.4.1": +"commander@npm:^9.4.0, commander@npm:^9.4.1": version: 9.5.0 resolution: "commander@npm:9.5.0" checksum: 10/41c49b3d0f94a1fbeb0463c85b13f15aa15a9e0b4d5e10a49c0a1d58d4489b549d62262b052ae0aa6cfda53299bee487bfe337825df15e342114dde543f82906 @@ -18639,10 +18733,10 @@ __metadata: languageName: node linkType: hard -"date-fns@npm:^3.3.0": - version: 3.3.1 - resolution: "date-fns@npm:3.3.1" - checksum: 10/98231936765dfb6fc6897676319b500a06a39f051b2c3ecbdd541a07ce9b1344b770277b8bfb1049fb7a2f70bf365ac8e6f1e2bb452b10e1a8101d518ca7f95d +"date-fns@npm:^3.3.1": + version: 3.4.0 + resolution: "date-fns@npm:3.4.0" + checksum: 10/38932c99ad28a69a18bd9e2331d0c86a46146d3e9efc92cbc612400a075785231c3271822948eb0377db738fa8a583aa4782d8246a939fe6f188727ea9323c29 languageName: node linkType: hard @@ -20021,6 +20115,86 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0, esbuild@npm:^0.20.1": + version: 0.20.1 + resolution: "esbuild@npm:0.20.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.20.1" + "@esbuild/android-arm": "npm:0.20.1" + "@esbuild/android-arm64": "npm:0.20.1" + "@esbuild/android-x64": "npm:0.20.1" + "@esbuild/darwin-arm64": "npm:0.20.1" + "@esbuild/darwin-x64": "npm:0.20.1" + "@esbuild/freebsd-arm64": "npm:0.20.1" + "@esbuild/freebsd-x64": "npm:0.20.1" + "@esbuild/linux-arm": "npm:0.20.1" + "@esbuild/linux-arm64": "npm:0.20.1" + "@esbuild/linux-ia32": "npm:0.20.1" + "@esbuild/linux-loong64": "npm:0.20.1" + "@esbuild/linux-mips64el": "npm:0.20.1" + "@esbuild/linux-ppc64": "npm:0.20.1" + "@esbuild/linux-riscv64": "npm:0.20.1" + "@esbuild/linux-s390x": "npm:0.20.1" + "@esbuild/linux-x64": "npm:0.20.1" + "@esbuild/netbsd-x64": "npm:0.20.1" + "@esbuild/openbsd-x64": "npm:0.20.1" + "@esbuild/sunos-x64": "npm:0.20.1" + "@esbuild/win32-arm64": "npm:0.20.1" + "@esbuild/win32-ia32": "npm:0.20.1" + "@esbuild/win32-x64": "npm:0.20.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/b672fd5df28ae917e2b16e77edbbf6b3099c390ab0a9d4cd331f78b4a4567cf33f506a055e1aa272ac90f7f522835b2173abea9bac6c38906acfda68e60a7ab7 + languageName: node + linkType: hard + "esbuild@npm:^0.19.3, esbuild@npm:esbuild@~0.17.6 || ~0.18.0 || ~0.19.0": version: 0.19.12 resolution: "esbuild@npm:0.19.12" @@ -20101,86 +20275,6 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.20.1": - version: 0.20.1 - resolution: "esbuild@npm:0.20.1" - dependencies: - "@esbuild/aix-ppc64": "npm:0.20.1" - "@esbuild/android-arm": "npm:0.20.1" - "@esbuild/android-arm64": "npm:0.20.1" - "@esbuild/android-x64": "npm:0.20.1" - "@esbuild/darwin-arm64": "npm:0.20.1" - "@esbuild/darwin-x64": "npm:0.20.1" - "@esbuild/freebsd-arm64": "npm:0.20.1" - "@esbuild/freebsd-x64": "npm:0.20.1" - "@esbuild/linux-arm": "npm:0.20.1" - "@esbuild/linux-arm64": "npm:0.20.1" - "@esbuild/linux-ia32": "npm:0.20.1" - "@esbuild/linux-loong64": "npm:0.20.1" - "@esbuild/linux-mips64el": "npm:0.20.1" - "@esbuild/linux-ppc64": "npm:0.20.1" - "@esbuild/linux-riscv64": "npm:0.20.1" - "@esbuild/linux-s390x": "npm:0.20.1" - "@esbuild/linux-x64": "npm:0.20.1" - "@esbuild/netbsd-x64": "npm:0.20.1" - "@esbuild/openbsd-x64": "npm:0.20.1" - "@esbuild/sunos-x64": "npm:0.20.1" - "@esbuild/win32-arm64": "npm:0.20.1" - "@esbuild/win32-ia32": "npm:0.20.1" - "@esbuild/win32-x64": "npm:0.20.1" - dependenciesMeta: - "@esbuild/aix-ppc64": - optional: true - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: 10/b672fd5df28ae917e2b16e77edbbf6b3099c390ab0a9d4cd331f78b4a4567cf33f506a055e1aa272ac90f7f522835b2173abea9bac6c38906acfda68e60a7ab7 - languageName: node - linkType: hard - "escalade@npm:^3.1.1": version: 3.1.1 resolution: "escalade@npm:3.1.1" @@ -20438,6 +20532,13 @@ __metadata: languageName: node linkType: hard +"eslint-visitor-keys@npm:^4.0.0": + version: 4.0.0 + resolution: "eslint-visitor-keys@npm:4.0.0" + checksum: 10/c7617166e6291a15ce2982b5c4b9cdfb6409f5c14562712d12e2584480cdf18609694b21d7dad35b02df0fa2cd037505048ded54d2f405c64f600949564eb334 + languageName: node + linkType: hard + "eslint@npm:^8.56.0": version: 8.56.0 resolution: "eslint@npm:8.56.0" @@ -20486,6 +20587,17 @@ __metadata: languageName: node linkType: hard +"espree@npm:^10.0.1": + version: 10.0.1 + resolution: "espree@npm:10.0.1" + dependencies: + acorn: "npm:^8.11.3" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^4.0.0" + checksum: 10/557d6cfb4894b1489effcaed8702682086033f8a2449568933bc59493734733d750f2a87907ba575844d3933340aea2d84288f5e67020c6152f6fd18a86497b2 + languageName: node + linkType: hard + "espree@npm:^9.3.1, espree@npm:^9.6.0, espree@npm:^9.6.1": version: 9.6.1 resolution: "espree@npm:9.6.1" @@ -20628,6 +20740,13 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^1.1.2": + version: 1.1.2 + resolution: "eventsource-parser@npm:1.1.2" + checksum: 10/14e94997dff896fb7bc85b29e89d6a62e747650d905b006249bcf48ba13efcbf3ee2b67948f4fa2638836b5a77ea079e25e941c0e41a720323e7867b1ebdda14 + languageName: node + linkType: hard + "execa@npm:8.0.1, execa@npm:^8.0.1": version: 8.0.1 resolution: "execa@npm:8.0.1" @@ -23963,7 +24082,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^5.0.4, istanbul-lib-instrument@npm:^5.1.0": +"istanbul-lib-instrument@npm:^5.0.4": version: 5.2.1 resolution: "istanbul-lib-instrument@npm:5.2.1" dependencies: @@ -23976,16 +24095,16 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.1": - version: 6.0.1 - resolution: "istanbul-lib-instrument@npm:6.0.1" +"istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.1, istanbul-lib-instrument@npm:^6.0.2": + version: 6.0.2 + resolution: "istanbul-lib-instrument@npm:6.0.2" dependencies: - "@babel/core": "npm:^7.12.3" - "@babel/parser": "npm:^7.14.7" - "@istanbuljs/schema": "npm:^0.1.2" + "@babel/core": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@istanbuljs/schema": "npm:^0.1.3" istanbul-lib-coverage: "npm:^3.2.0" semver: "npm:^7.5.4" - checksum: 10/95fd8c66e586840989cb3c7819c6da66c4742a6fedbf16b51a5c7f1898941ad07b79ddff020f479d3a1d76743ecdbf255d93c35221875687477d4b118026e7e7 + checksum: 10/3aee19be199350182827679a137e1df142a306e9d7e20bb5badfd92ecc9023a7d366bc68e7c66e36983654a02a67401d75d8debf29fc6d4b83670fde69a594fc languageName: node linkType: hard @@ -24685,13 +24804,6 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.11.4, jose@npm:^4.15.1": - version: 4.15.4 - resolution: "jose@npm:4.15.4" - checksum: 10/20fa941597150dffc7af3f41d994500cc3e71cd650b755243dbd80d91cf26c1053f95b78af588f05cfc4371e492a67c5c7a48f689b8605145a8fe28b484d725b - languageName: node - linkType: hard - "jose@npm:^5.0.0, jose@npm:^5.1.3": version: 5.2.2 resolution: "jose@npm:5.2.2" @@ -25154,15 +25266,16 @@ __metadata: languageName: node linkType: hard -"lib0@npm:^0.2.74, lib0@npm:^0.2.85, lib0@npm:^0.2.86, lib0@npm:^0.2.88, lib0@npm:^0.2.89": - version: 0.2.89 - resolution: "lib0@npm:0.2.89" +"lib0@npm:^0.2.74, lib0@npm:^0.2.85, lib0@npm:^0.2.86, lib0@npm:^0.2.89, lib0@npm:^0.2.91": + version: 0.2.91 + resolution: "lib0@npm:0.2.91" dependencies: isomorphic.js: "npm:^0.2.4" bin: + 0ecdsa-generate-keypair: bin/0ecdsa-generate-keypair.js 0gentesthtml: bin/gentesthtml.js 0serve: bin/0serve.js - checksum: 10/a86120bd636120a992963658e08597172e882740224cf889e22b33dacb70287a25285c27e81a578892b2b6dc18515ac30f8d0cd75a8dd163fef397382a0392c4 + checksum: 10/9706a39d9bc9c309bebf4f380dedeca586aa07c4c54466f3c4ffa394c02b45c8339e80390af06acb94b898b601bb8d7828a830df6a7faa46a61dc37c58b489d8 languageName: node linkType: hard @@ -25320,7 +25433,7 @@ __metadata: languageName: node linkType: hard -"lit@npm:^3.1.1, lit@npm:^3.1.2": +"lit@npm:^3.1.2": version: 3.1.2 resolution: "lit@npm:3.1.2" dependencies: @@ -27577,7 +27690,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^5.0.4, nanoid@npm:^5.0.6": +"nanoid@npm:^5.0.6": version: 5.0.6 resolution: "nanoid@npm:5.0.6" bin: @@ -27637,64 +27750,13 @@ __metadata: languageName: node linkType: hard -"next-auth@npm:4.24.5": - version: 4.24.5 - resolution: "next-auth@npm:4.24.5" - dependencies: - "@babel/runtime": "npm:^7.20.13" - "@panva/hkdf": "npm:^1.0.2" - cookie: "npm:^0.5.0" - jose: "npm:^4.11.4" - oauth: "npm:^0.9.15" - openid-client: "npm:^5.4.0" - preact: "npm:^10.6.3" - preact-render-to-string: "npm:^5.1.19" - uuid: "npm:^8.3.2" +"next-themes@npm:^0.3.0": + version: 0.3.0 + resolution: "next-themes@npm:0.3.0" peerDependencies: - next: ^12.2.5 || ^13 || ^14 - nodemailer: ^6.6.5 - react: ^17.0.2 || ^18 - react-dom: ^17.0.2 || ^18 - peerDependenciesMeta: - nodemailer: - optional: true - checksum: 10/c9256deaa7a77741be2c8829c290c43c63fd8fa86ace3196910d3fa4389c101d6a610f3c5f4b55000e766a51dd89eafc9b5cd876e373884db3bf90122fdfa6a1 - languageName: node - linkType: hard - -"next-auth@patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch": - version: 4.24.5 - resolution: "next-auth@patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch::version=4.24.5&hash=9af7e1" - dependencies: - "@babel/runtime": "npm:^7.20.13" - "@panva/hkdf": "npm:^1.0.2" - cookie: "npm:^0.5.0" - jose: "npm:^4.11.4" - oauth: "npm:^0.9.15" - openid-client: "npm:^5.4.0" - preact: "npm:^10.6.3" - preact-render-to-string: "npm:^5.1.19" - uuid: "npm:^8.3.2" - peerDependencies: - next: ^12.2.5 || ^13 || ^14 - nodemailer: ^6.6.5 - react: ^17.0.2 || ^18 - react-dom: ^17.0.2 || ^18 - peerDependenciesMeta: - nodemailer: - optional: true - checksum: 10/15f251a6e31c79459bce7a2d638c6069c34b5e92effdae8d7b2c366bbe2d1e1916da6ed5bc7995c1926dd35442552deb33959ee4bd45bbab0347455c13448d4b - languageName: node - linkType: hard - -"next-themes@npm:^0.2.1": - version: 0.2.1 - resolution: "next-themes@npm:0.2.1" - peerDependencies: - next: "*" - react: "*" - react-dom: "*" - checksum: 10/6c955c114b7aa920fc14edd3832d0ea95be245ad33f79f397f613696a7c16c7f4112d6e61893d4977255b270f920811741eafdf8be49bd99cecafabf09e8a499 + react: ^16.8 || ^17 || ^18 + react-dom: ^16.8 || ^17 || ^18 + checksum: 10/4285c4969eac517ad7addd773bcb71e7d14bc6c6e3b24eb97b80a6e06ac03fb6cb345e75dfb448156d14430d06289948eb8cfdeb52402ca7ce786093d01d2878 languageName: node linkType: hard @@ -27790,7 +27852,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^2, node-fetch@npm:^2.0.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.2, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9": +"node-fetch@npm:^2.0.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.2, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -28112,21 +28174,21 @@ __metadata: languageName: node linkType: hard -"nx@npm:18.0.7, nx@npm:^18.0.4": - version: 18.0.7 - resolution: "nx@npm:18.0.7" +"nx@npm:18.0.8, nx@npm:^18.0.4": + version: 18.0.8 + resolution: "nx@npm:18.0.8" dependencies: - "@nrwl/tao": "npm:18.0.7" - "@nx/nx-darwin-arm64": "npm:18.0.7" - "@nx/nx-darwin-x64": "npm:18.0.7" - "@nx/nx-freebsd-x64": "npm:18.0.7" - "@nx/nx-linux-arm-gnueabihf": "npm:18.0.7" - "@nx/nx-linux-arm64-gnu": "npm:18.0.7" - "@nx/nx-linux-arm64-musl": "npm:18.0.7" - "@nx/nx-linux-x64-gnu": "npm:18.0.7" - "@nx/nx-linux-x64-musl": "npm:18.0.7" - "@nx/nx-win32-arm64-msvc": "npm:18.0.7" - "@nx/nx-win32-x64-msvc": "npm:18.0.7" + "@nrwl/tao": "npm:18.0.8" + "@nx/nx-darwin-arm64": "npm:18.0.8" + "@nx/nx-darwin-x64": "npm:18.0.8" + "@nx/nx-freebsd-x64": "npm:18.0.8" + "@nx/nx-linux-arm-gnueabihf": "npm:18.0.8" + "@nx/nx-linux-arm64-gnu": "npm:18.0.8" + "@nx/nx-linux-arm64-musl": "npm:18.0.8" + "@nx/nx-linux-x64-gnu": "npm:18.0.8" + "@nx/nx-linux-x64-musl": "npm:18.0.8" + "@nx/nx-win32-arm64-msvc": "npm:18.0.8" + "@nx/nx-win32-x64-msvc": "npm:18.0.8" "@yarnpkg/lockfile": "npm:^1.1.0" "@yarnpkg/parsers": "npm:3.0.0-rc.46" "@zkochan/js-yaml": "npm:0.0.6" @@ -28192,7 +28254,7 @@ __metadata: bin: nx: bin/nx.js nx-cloud: bin/nx-cloud.js - checksum: 10/c9bee9bd161a7eca730db0a95a941eaddac1a0eb4453a691d498b61d4b15e9fac0b2bdf0a38485104c139147975b9dd3e5d709cb0a45c4d100e04db887f26e5f + checksum: 10/f590a9a8d314161555c4a3d8fa7b2e57997eb7c3cd8d9639c24e735af23299850843898da1c682c08b6f60d1b9ab3e99e29b3caa8be8a3bd654419f427d7caa9 languageName: node linkType: hard @@ -28240,13 +28302,6 @@ __metadata: languageName: node linkType: hard -"oauth@npm:^0.9.15": - version: 0.9.15 - resolution: "oauth@npm:0.9.15" - checksum: 10/6b0b10be19a461da417a37ea2821a773ef74dd667563291e1e83b2024b88e6571b0323a0a6887f2390fbaf28cc6ce5bfe0484fc22162b975305b1e19b76f5597 - languageName: node - linkType: hard - "object-assign@npm:^4, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -28261,13 +28316,6 @@ __metadata: languageName: node linkType: hard -"object-hash@npm:^2.2.0": - version: 2.2.0 - resolution: "object-hash@npm:2.2.0" - checksum: 10/dee06b6271bf5769ae5f1a7386fdd52c1f18aae9fcb0b8d4bb1232f2d743d06cb5b662be42378b60a1c11829f96f3f86834a16bbaa57a085763295fff8b93e27 - languageName: node - linkType: hard - "object-is@npm:@nolyfill/object-is@latest": version: 1.0.24 resolution: "@nolyfill/object-is@npm:1.0.24" @@ -28345,13 +28393,6 @@ __metadata: languageName: node linkType: hard -"oidc-token-hash@npm:^5.0.3": - version: 5.0.3 - resolution: "oidc-token-hash@npm:5.0.3" - checksum: 10/35fa19aea9ff2c509029ec569d74b778c8a215b92bd5e6e9bc4ebbd7ab035f44304ff02430a6397c3fb7c1d15ebfa467807ca0bcd31d06ba610b47798287d303 - languageName: node - linkType: hard - "on-finished@npm:2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -28418,9 +28459,9 @@ __metadata: languageName: node linkType: hard -"openai@npm:^4.25.0": - version: 4.25.0 - resolution: "openai@npm:4.25.0" +"openai@npm:^4.28.4": + version: 4.28.4 + resolution: "openai@npm:4.28.4" dependencies: "@types/node": "npm:^18.11.18" "@types/node-fetch": "npm:^2.6.4" @@ -28433,19 +28474,7 @@ __metadata: web-streams-polyfill: "npm:^3.2.1" bin: openai: bin/cli - checksum: 10/58e05f25b4ee8ffa048d92257c6696734b75997ecfdc56d99d6a43ce6bdf2db994d4343439d2d2832710a257af481c08f0ad4bd34f664e99b01286c67aac2d10 - languageName: node - linkType: hard - -"openid-client@npm:^5.4.0": - version: 5.6.1 - resolution: "openid-client@npm:5.6.1" - dependencies: - jose: "npm:^4.15.1" - lru-cache: "npm:^6.0.0" - object-hash: "npm:^2.2.0" - oidc-token-hash: "npm:^5.0.3" - checksum: 10/8f2485438048def1bab680a634fd4ebb85bfb0d6a12d6490ef7a0f8189688db1920fff831ed23e70f59bc15d51ba6a33fca1313f0fba28b162c61e81c7e0649c + checksum: 10/81bf6f832f43aa81c8fca1bd1f8ffde16f02fcfa13d5cb66b64cd9440c7074054a4a766b0c9b6c9217359f96a376619f91e7071121739afe6eb492992ebfd2ec languageName: node linkType: hard @@ -29693,17 +29722,6 @@ __metadata: languageName: node linkType: hard -"preact-render-to-string@npm:^5.1.19": - version: 5.2.6 - resolution: "preact-render-to-string@npm:5.2.6" - dependencies: - pretty-format: "npm:^3.8.0" - peerDependencies: - preact: ">=10" - checksum: 10/356519f7640d1c49e11b4837b41a83b307f3f237f93de153b9dde833a701e3ce5cf1d45cb18e37a3ec9c568555e2f5373c128d8b5f6ef79de7658f3c400d3e70 - languageName: node - linkType: hard - "preact@npm:10.11.3": version: 10.11.3 resolution: "preact@npm:10.11.3" @@ -29711,13 +29729,6 @@ __metadata: languageName: node linkType: hard -"preact@npm:^10.6.3": - version: 10.19.2 - resolution: "preact@npm:10.19.2" - checksum: 10/1519050e79f0dec61aa85daa5dcba4a5294e89fb09ab53d5e1a215ef8526dd5ccdbe82a02842cc4875fa3ea076eee9697a7421c32ffcc6159007d27b13a60a8f - languageName: node - linkType: hard - "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -30701,16 +30712,16 @@ __metadata: languageName: node linkType: hard -"recast@npm:^0.23.1, recast@npm:^0.23.3": - version: 0.23.4 - resolution: "recast@npm:0.23.4" +"recast@npm:^0.23.1, recast@npm:^0.23.3, recast@npm:^0.23.5": + version: 0.23.6 + resolution: "recast@npm:0.23.6" dependencies: - assert: "npm:^2.0.0" ast-types: "npm:^0.16.1" esprima: "npm:~4.0.0" source-map: "npm:~0.6.1" + tiny-invariant: "npm:^1.3.3" tslib: "npm:^2.0.1" - checksum: 10/a82e388ded2154697ea54e6d65d060143c9cf4b521f770232a7483e253d45bdd9080b44dc5874d36fe720ba1a10cb20b95375896bd89f5cab631a751e93979f5 + checksum: 10/3b7bfac05a4ec427738f3a9dc3c955a863eb5bdf42817310a2f521da127613f833c648acee95fd11b4c906186a0b283d873b787d72e3d323a0f42abfcaf4b1f9 languageName: node linkType: hard @@ -32199,7 +32210,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.7.3": +"source-map@npm:^0.7.3, source-map@npm:^0.7.4": version: 0.7.4 resolution: "source-map@npm:0.7.4" checksum: 10/a0f7c9b797eda93139842fd28648e868a9a03ea0ad0d9fa6602a0c1f17b7fb6a7dcca00c144476cccaeaae5042e99a285723b1a201e844ad67221bf5d428f1dc @@ -33312,10 +33323,10 @@ __metadata: languageName: node linkType: hard -"tiny-invariant@npm:^1.3.1": - version: 1.3.1 - resolution: "tiny-invariant@npm:1.3.1" - checksum: 10/872dbd1ff20a21303a2fd20ce3a15602cfa7fcf9b228bd694a52e2938224313b5385a1078cb667ed7375d1612194feaca81c4ecbe93121ca1baebe344de4f84c +"tiny-invariant@npm:^1.3.1, tiny-invariant@npm:^1.3.3": + version: 1.3.3 + resolution: "tiny-invariant@npm:1.3.3" + checksum: 10/5e185c8cc2266967984ce3b352a4e57cb89dad5a8abb0dea21468a6ecaa67cd5bb47a3b7a85d08041008644af4f667fb8b6575ba38ba5fb00b3b5068306e59fe languageName: node linkType: hard @@ -34641,18 +34652,19 @@ __metadata: languageName: node linkType: hard -"vite-plugin-istanbul@npm:^5.0.0": - version: 5.0.0 - resolution: "vite-plugin-istanbul@npm:5.0.0" +"vite-plugin-istanbul@npm:^6.0.0": + version: 6.0.0 + resolution: "vite-plugin-istanbul@npm:6.0.0" dependencies: "@istanbuljs/load-nyc-config": "npm:^1.1.0" - espree: "npm:^9.6.1" - istanbul-lib-instrument: "npm:^5.1.0" + espree: "npm:^10.0.1" + istanbul-lib-instrument: "npm:^6.0.2" picocolors: "npm:^1.0.0" + source-map: "npm:^0.7.4" test-exclude: "npm:^6.0.0" peerDependencies: - vite: ">=2.9.1 <= 5" - checksum: 10/1c2ae560699f88fc89ea77854329e657e41a22232ae91e5eb0080cf794ed16aec6a549f6db03920986cc5c8b81566efb365a3dad791c6a8e79431a82ceb006b7 + vite: ">=4 <=6" + checksum: 10/9a989707b7d6faed1d3d6e58f246f4632d229d0d5524b1886a0338f7ec082b37a172a648132bced5a3e439639a0052dfe5117123944ac7c352df73298687c34a languageName: node linkType: hard @@ -35623,7 +35635,7 @@ __metadata: version: 0.0.0-use.local resolution: "y-provider@workspace:packages/common/y-provider" dependencies: - "@blocksuite/store": "npm:0.13.0-canary-202403050653-934469c" + "@blocksuite/store": "npm:0.13.0-canary-202403140320-a2b362b" vite: "npm:^5.1.4" vite-plugin-dts: "npm:3.7.3" vitest: "npm:1.3.1"