Compare commits

...

71 Commits

Author SHA1 Message Date
Alex Yang
9f0e67a673 v0.9.0-canary.8 2023-09-02 01:22:50 -05:00
Qi
138aaed05d feat: add a reminder for early access in the invitation email (#4097)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-09-02 06:07:49 +00:00
Qi
3edfc46307 feat: optimize sign in experience (#4099) 2023-09-02 06:07:38 +00:00
Alex Yang
be9ae57a8e test: cover basic collaborative (#4127) 2023-09-02 01:06:47 -05:00
Alex Yang
4f97ea8a5d test: cover share page e2e (#4126) 2023-09-02 00:57:04 -05:00
Alex Yang
8825678ca9 test: add name change test (#4125) 2023-09-02 00:11:10 -05:00
Alex Yang
70b5a9deeb test: improve data migration suite (#4124) 2023-09-02 03:31:07 +00:00
Alex Yang
eb1a21265f refactor(server): use ava (#4120) 2023-09-01 19:41:29 +00:00
JimmFly
8845bb9b4b chore: optimized style (#4098) 2023-09-01 19:28:16 +00:00
Whitewater
189e91e6ca fix(core): sort tags by count (#4122) 2023-09-01 19:13:33 +00:00
Qi
442d06fc69 fix: error invitation url (#4110) 2023-09-01 18:22:26 +00:00
Peng Xiao
c9c76983de fix: cookie issues in Electron (#4115) 2023-09-01 17:34:08 +00:00
DarkSky
3c4f45bcb6 feat: add user info edit verify (#4117) 2023-09-01 16:59:33 +00:00
LongYinan
db3a6efaf3 build(core): fix non-canary assets bucket (#4116) 2023-09-02 00:32:11 +08:00
DarkSky
7d3b1ad2b9 feat: add user level blob quota (#4114) 2023-09-01 12:56:27 +00:00
LongYinan
e76cdf4d71 fix(server): set right AFFINE_SERVER_HOST env variable (#4108) 2023-09-01 18:37:48 +08:00
LongYinan
18ac355df3 chore(server): change the log level (#4106) 2023-09-01 18:37:31 +08:00
X1a0t
c0bf82d3ff fix: beta serverUrlPrefix (#4103) 2023-09-01 04:27:12 -05:00
Qi
a1f4cbc568 fix: error in @toeverything/components (#4102) 2023-09-01 09:00:37 +00:00
Yifeng Wang
10c609348f fix: preload typo (#4096) 2023-09-01 06:59:06 +00:00
Alex Yang
88f94d5b61 test(server): run test in single thread (#4095) 2023-09-01 01:25:18 -05:00
Alex Yang
92f0b31196 feat: support force sync by click (#4089)
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
2023-09-01 01:15:07 -05:00
LongYinan
83e7e9db8d fix(server): relax the rate limits (#4092) 2023-09-01 13:51:37 +08:00
LongYinan
3f21b0b45d fix(server): redirect logic in earlyAccessPreview (#4091) 2023-09-01 13:51:20 +08:00
Peng Xiao
d4a2b3f4d1 fix: not be able to login with Google in desktop (#4093) 2023-08-31 23:34:55 -05:00
X1a0t
d4a83c1c6f feat: exception logger (#4059) 2023-09-01 12:05:35 +08:00
Peng Xiao
b0024080bd fix: add back sourcemaps to electron build (#4090) 2023-09-01 03:34:18 +00:00
Alex Yang
c937b88978 test(server): fix flaky (#4088) 2023-09-01 01:03:46 +00:00
Ricardo Emanuel
0f2223ddf0 docs: fixed typo in README.md of the root (#4049) 2023-08-31 19:08:56 -05:00
Alex Yang
364fc517cc docs: update BUILDING.md (#4087) 2023-08-31 18:50:54 -05:00
Alex Yang
25671e2134 chore: bump version (#4083) 2023-08-31 18:50:03 -05:00
Alex Yang
1e30a3c7fe fix(core): forwardRef in count down render (#4086) 2023-08-31 18:37:56 -05:00
Alex Yang
06d5ecd597 docs: update indexeddb document (#4084) 2023-08-31 17:16:27 -05:00
KaranPant
b18596fc57 fix: show border around pagetitle when renaming (#4080) 2023-08-31 17:06:58 -05:00
Alex Yang
7082937b62 refactor(workspace): sync doc update in background using data source (#4081) 2023-08-31 16:20:57 -05:00
Pranay Prajapati
4091ff8e36 fix: corrected the preposition in "Save As New Collection" (#4070) 2023-08-31 13:35:10 -05:00
Alex Yang
0fa1bdf7d2 v0.9.0-canary.7 2023-08-31 13:18:25 -05:00
JimmFly
df4d71b0c8 feat: add worksapce type label (#4045)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-08-31 13:16:58 -05:00
Priyansh Gupta
18d5a99af5 feat(core): added code to handle keyboard inputs (#4006)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-08-31 13:15:55 -05:00
JimmFly
6be176b4e3 fix: the web version should not display client borders (#4040)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-08-31 13:15:22 -05:00
Alex Yang
97a0969583 fix(core): skip background syncing in the web (#4077) 2023-08-31 12:59:34 -05:00
Peng Xiao
a2e4ef904b refactor: remove hacky email login (#4075)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-08-31 12:49:22 -05:00
Qi
f99a7a5ecd fix: shortcut key style (#4072) 2023-08-31 08:08:10 -05:00
Alex Yang
f21426d23d fix(core): blockVersions check (#4073) 2023-08-31 08:07:21 -05:00
Qi
3f5e649295 fix: sign in issues (#4047)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-08-31 08:07:05 -05:00
Peng Xiao
13857d59dc fix: some style issues to sidebar and switch (#4046)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-08-31 20:46:06 +08:00
Qi
260c25acf3 feat: add storage panel in setting (#4069) 2023-08-31 07:36:05 -05:00
DarkSky
4ef1425299 feat: rate limiter (#4011) 2023-08-31 20:29:25 +08:00
Peng Xiao
8e48255ef8 fix: userinfo title (#4068) 2023-08-31 09:46:26 +00:00
liuyi
e10868cd20 fix(server): deal with unexpected updates (#4064) 2023-08-31 16:56:33 +08:00
LongYinan
9bffe3cf24 fix(server): do not override auth.privateKey (#4065) 2023-08-31 16:44:37 +08:00
DarkSky
0add43f8db feat: blob size api (#4060) 2023-08-31 16:39:19 +08:00
LongYinan
cc00da9325 chore(server): enable earlyAccessPreview for canary (#4061) 2023-08-31 14:41:43 +08:00
Alex Yang
49d203ac57 v0.9.0-canary.6 2023-08-31 00:48:00 -05:00
Alex Yang
55b3182799 feat(core): support syncing workspaces and blobs in the background (#4057) 2023-08-31 00:40:34 -05:00
Peng Xiao
4e45554585 feat: support google login on desktop (#4053)
Co-authored-by: Alex Yang <himself65@outlook.com>
2023-08-31 12:51:49 +08:00
X1a0t
ba735d8b57 chore: bump octobase (#4042) 2023-08-30 21:06:50 -05:00
Alex Yang
517f4afb31 fix(core): refresh metadata after refresh (#4054) 2023-08-30 21:22:34 +00:00
X1a0t
441e706746 fix: flaky unit test should be able to timer (#4043) 2023-08-30 13:58:25 -05:00
Alex Yang
7c4e65a5be ci: use 'pull_request' on publish-storybook.yml (#4051) 2023-08-30 11:09:04 -05:00
Alex Yang
e042152681 ci: update chromatic build (#4050) 2023-08-30 10:55:28 -05:00
Alex Yang
2e042e03b2 v0.9.0-canary.5 2023-08-30 09:51:35 -05:00
Peng Xiao
d6c0e67bf0 fix: electron white screen (#4048) 2023-08-30 09:41:06 -05:00
Alex Yang
e75ff52ec1 v0.9.0-canary.4 2023-08-30 00:13:22 -05:00
Alex Yang
00e7cf9a06 fix(core): incorrect blocksuite data format (#4039) 2023-08-30 00:04:16 -05:00
Peng Xiao
82f8ac50de fix: replace dmg bg (#4038) 2023-08-29 23:30:45 -05:00
Alex Yang
880375a6d1 chore: bump version (#4028) 2023-08-29 23:30:15 -05:00
Alex Yang
02bd9fc2d1 fix(core): find lost data (#4035) 2023-08-29 23:30:03 -05:00
Peng Xiao
cbb5b6e4a5 fix: crash on close (#4033) 2023-08-30 03:06:43 +00:00
DarkSky
d3bd369420 chore: add bump octobase script (#3931) 2023-08-29 19:37:12 -05:00
Alex Yang
4aabe2ea5e refactor(core): use element atom (#4026) 2023-08-29 18:59:39 -05:00
257 changed files with 6281 additions and 3889 deletions

View File

@@ -37,6 +37,11 @@ const createPattern = packageName => [
// useSession is type unsafe // useSession is type unsafe
importNames: ['useSession'], importNames: ['useSession'],
}, },
{
group: ['next-auth/react'],
message: "Import hooks from 'cloud-utils.ts'",
importNames: ['signIn', 'signOut'],
},
{ {
group: ['yjs'], group: ['yjs'],
message: 'Do not use this API because it has a bug', message: 'Do not use this API because it has a bug',
@@ -172,6 +177,11 @@ const config = {
// useSession is type unsafe // useSession is type unsafe
importNames: ['useSession'], importNames: ['useSession'],
}, },
{
group: ['next-auth/react'],
message: "Import hooks from 'cloud-utils.ts'",
importNames: ['signIn', 'signOut'],
},
{ {
group: ['yjs'], group: ['yjs'],
message: 'Do not use this API because it has a bug', message: 'Do not use this API because it has a bug',

View File

@@ -67,18 +67,20 @@ const createHelmCommand = ({ isDryRun }) => {
const graphqlReplicaCount = isProduction ? 3 : isBeta ? 2 : 2; const graphqlReplicaCount = isProduction ? 3 : isBeta ? 2 : 2;
const syncReplicaCount = isProduction ? 6 : isBeta ? 3 : 2; const syncReplicaCount = isProduction ? 6 : isBeta ? 3 : 2;
const namespace = isProduction ? 'production' : isBeta ? 'beta' : 'dev'; const namespace = isProduction ? 'production' : isBeta ? 'beta' : 'dev';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const host = DEPLOY_HOST || CANARY_DEPLOY_HOST;
const deployCommand = [ const deployCommand = [
`helm upgrade --install affine .github/helm/affine`, `helm upgrade --install affine .github/helm/affine`,
`--namespace ${namespace}`, `--namespace ${namespace}`,
`--set global.ingress.enabled=true`, `--set global.ingress.enabled=true`,
`--set-json global.ingress.annotations=\"{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${staticIpName}\\" }\"`, `--set-json global.ingress.annotations=\"{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${staticIpName}\\" }\"`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion `--set-string global.ingress.host="${host}"`,
`--set-string global.ingress.host="${DEPLOY_HOST || CANARY_DEPLOY_HOST}"`,
...redisAndPostgres, ...redisAndPostgres,
`--set web.replicaCount=${webReplicaCount}`, `--set web.replicaCount=${webReplicaCount}`,
`--set-string web.image.tag="${imageTag}"`, `--set-string web.image.tag="${imageTag}"`,
`--set graphql.replicaCount=${graphqlReplicaCount}`, `--set graphql.replicaCount=${graphqlReplicaCount}`,
`--set-string graphql.image.tag="${imageTag}"`, `--set-string graphql.image.tag="${imageTag}"`,
`--set graphql.app.host=${host}`,
`--set graphql.app.objectStorage.r2.enabled=true`, `--set graphql.app.objectStorage.r2.enabled=true`,
`--set-string graphql.app.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`, `--set-string graphql.app.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`,
`--set-string graphql.app.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`, `--set-string graphql.app.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`,

View File

@@ -91,15 +91,13 @@ jobs:
- name: Generate prisma client - name: Generate prisma client
run: | run: |
yarn exec prisma generate yarn workspace @affine/server exec prisma generate
yarn exec prisma db push yarn workspace @affine/server exec prisma db push
working-directory: apps/server
env: env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Run init-db script - name: Run init-db script
run: yarn exec ts-node-esm ./scripts/init-db.ts run: yarn workspace @affine/server exec ts-node-esm ./scripts/init-db.ts
working-directory: apps/server
env: env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -110,8 +108,7 @@ jobs:
path: ./apps/server path: ./apps/server
- name: Run server tests - name: Run server tests
run: yarn test:coverage run: yarn workspace @affine/server test:coverage
working-directory: apps/server
env: env:
CARGO_TARGET_DIR: '${{ github.workspace }}/target' CARGO_TARGET_DIR: '${{ github.workspace }}/target'
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine DATABASE_URL: postgresql://affine:affine@localhost:5432/affine

View File

@@ -4,15 +4,19 @@ env:
NODE_OPTIONS: --max-old-space-size=4096 NODE_OPTIONS: --max-old-space-size=4096
on: on:
workflow_dispatch:
push: push:
branches: branches:
- master - master
pull_request_target: pull_request:
branches: branches:
- master - master
paths-ignore: paths-ignore:
- README.md - README.md
- .github/** - .github/**
- apps/server
- apps/docs
- apps/electron
- '!.github/workflows/publish-storybook.yml' - '!.github/workflows/publish-storybook.yml'
jobs: jobs:
@@ -32,12 +36,20 @@ jobs:
electron-install: false electron-install: false
- name: Build Plugins - name: Build Plugins
run: yarn run build:plugins run: yarn run build:plugins
- name: Publish to Chromatic - uses: chromaui/action-next@v1
uses: chromaui/action@v1
with: with:
workingDir: apps/storybook workingDir: apps/storybook
buildScriptName: build buildScriptName: build
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} exitOnceUploaded: true
zip: true onlyChanged: false
diagnostics: true
env: env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
NODE_OPTIONS: ${{ env.NODE_OPTIONS }} NODE_OPTIONS: ${{ env.NODE_OPTIONS }}
- uses: actions/upload-artifact@v2
if: always()
with:
name: chromatic-build-artifacts-${{ github.run_id }}
path: |
chromatic-diagnostics.json
**/build-storybook.log

489
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -77,7 +77,7 @@ Star us, and you will receive all releases notifications from GitHub without any
- **Hyper merged** — Write, draw and plan all at once. Assemble any blocks you love on any canvas you like to enjoy seamless transitions between workflows with AFFiNE. - **Hyper merged** — Write, draw and plan all at once. Assemble any blocks you love on any canvas you like to enjoy seamless transitions between workflows with AFFiNE.
- **Privacy focussed** — AFFiNE is built with your privacy in mind and is one of our key concerns. We want you to keep control of your data, allowing you to store it as you like, where you like while still being able to freely edit and view your data on-demand. - **Privacy focussed** — AFFiNE is built with your privacy in mind and is one of our key concerns. We want you to keep control of your data, allowing you to store it as you like, where you like while still being able to freely edit and view your data on-demand.
- **Offline-first** - With your privacy in mind we also decided to go offline-first. This means that AFFiNE can be used offline, whether you want to view or edit, with support for conflict-free merging when you are back online. - **Offline-first** With your privacy in mind we also decided to go offline-first. This means that AFFiNE can be used offline, whether you want to view or edit, with support for conflict-free merging when you are back online.
- **Clean, intuitive design** — With AFFiNE you can concentrate on editing with a clean and modern interface. Which is responsive, so it looks great on tablets too, and mobile support is coming in the future. - **Clean, intuitive design** — With AFFiNE you can concentrate on editing with a clean and modern interface. Which is responsive, so it looks great on tablets too, and mobile support is coming in the future.
- **Modern Block Editor with Markdown support** — A modern block editor can help you not only for docs, but slides and tables as well. When you write in AFFiNE you can use Markdown syntax which helps create an easier editing experience, that can be experienced with just a keyboard. And this allows you to export your data cleanly into Markdown. - **Modern Block Editor with Markdown support** — A modern block editor can help you not only for docs, but slides and tables as well. When you write in AFFiNE you can use Markdown syntax which helps create an easier editing experience, that can be experienced with just a keyboard. And this allows you to export your data cleanly into Markdown.
- **Collaboration** — Whether you want to collaborate with yourself across multiple devices, or work together with others, support for collaboration and multiplayer is out-of-the-box, which makes it easy for teams to get started with AFFiNE. - **Collaboration** — Whether you want to collaborate with yourself across multiple devices, or work together with others, support for collaboration and multiplayer is out-of-the-box, which makes it easy for teams to get started with AFFiNE.

View File

@@ -128,9 +128,7 @@ export const createConfiguration: (
devtool: devtool:
buildFlags.mode === 'production' buildFlags.mode === 'production'
? buildFlags.distribution === 'desktop' ? 'source-map'
? 'nosources-source-map'
: 'source-map'
: 'eval-cheap-module-source-map', : 'eval-cheap-module-source-map',
resolve: { resolve: {

View File

@@ -48,7 +48,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
get beta() { get beta() {
return { return {
...this.stable, ...this.stable,
serverUrlPrefix: 'https://ambassador.affine.pro', serverUrlPrefix: 'https://insider.affine.pro',
}; };
}, },
get internal() { get internal() {

View File

@@ -2,7 +2,7 @@
"name": "@affine/core", "name": "@affine/core",
"type": "module", "type": "module",
"private": true, "private": true,
"version": "0.9.0-canary.3", "version": "0.9.0-canary.8",
"scripts": { "scripts": {
"build": "yarn -T run build-core", "build": "yarn -T run build-core",
"dev": "yarn -T run dev-core", "dev": "yarn -T run dev-core",
@@ -24,13 +24,13 @@
"@affine/jotai": "workspace:*", "@affine/jotai": "workspace:*",
"@affine/templates": "workspace:*", "@affine/templates": "workspace:*",
"@affine/workspace": "workspace:*", "@affine/workspace": "workspace:*",
"@blocksuite/block-std": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/block-std": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/blocks": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/blocks": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/editor": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/editor": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/global": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/global": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/icons": "^2.1.31", "@blocksuite/icons": "^2.1.31",
"@blocksuite/lit": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/lit": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/store": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/store": "0.0.0-20230829150056-df43987c-nightly",
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@emotion/cache": "^11.11.0", "@emotion/cache": "^11.11.0",
@@ -40,17 +40,16 @@
"@mui/material": "^5.14.7", "@mui/material": "^5.14.7",
"@radix-ui/react-select": "^1.2.2", "@radix-ui/react-select": "^1.2.2",
"@react-hookz/web": "^23.1.0", "@react-hookz/web": "^23.1.0",
"@toeverything/components": "^0.0.24", "@toeverything/components": "^0.0.25",
"async-call-rpc": "^6.3.1", "async-call-rpc": "^6.3.1",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"css-spring": "^4.1.0", "css-spring": "^4.1.0",
"cssnano": "^6.0.1", "cssnano": "^6.0.1",
"graphql": "^16.8.0", "graphql": "^16.8.0",
"intl-segmenter-polyfill-rs": "^0.1.6", "intl-segmenter-polyfill-rs": "^0.1.6",
"jotai": "^2.4.0", "jotai": "^2.4.1",
"jotai-devtools": "^0.6.2", "jotai-devtools": "^0.6.2",
"lit": "^2.8.0", "lit": "^2.8.0",
"lodash.debounce": "^4.0.8",
"lottie-web": "^5.12.2", "lottie-web": "^5.12.2",
"mini-css-extract-plugin": "^2.7.6", "mini-css-extract-plugin": "^2.7.6",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
@@ -75,9 +74,8 @@
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@sentry/webpack-plugin": "^2.7.0", "@sentry/webpack-plugin": "^2.7.0",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@swc/core": "^1.3.80", "@swc/core": "^1.3.81",
"@types/lodash-es": "^4.17.8", "@types/lodash-es": "^4.17.9",
"@types/lodash.debounce": "^4.0.7",
"@types/webpack-env": "^1.18.1", "@types/webpack-env": "^1.18.1",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1", "css-loader": "^6.8.1",

View File

@@ -9,8 +9,9 @@ import {
ReleaseType, ReleaseType,
WorkspaceFlavour, WorkspaceFlavour,
} from '@affine/env/workspace'; } from '@affine/env/workspace';
import { CRUD as CloudCRUD } from '@affine/workspace/affine/crud';
import { startSync, stopSync } from '@affine/workspace/affine/sync';
import { CRUD as CloudCRUD } from './cloud/crud';
import { UI as CloudUI } from './cloud/ui'; import { UI as CloudUI } from './cloud/ui';
import { LocalAdapter } from './local'; import { LocalAdapter } from './local';
import { UI as PublicCloudUI } from './public-cloud/ui'; import { UI as PublicCloudUI } from './public-cloud/ui';
@@ -40,6 +41,8 @@ export const WorkspaceAdapters = {
return false; return false;
} }
}, },
'service:start': startSync,
'service:stop': stopSync,
} as Partial<AppEvents>, } as Partial<AppEvents>,
CRUD: CloudCRUD, CRUD: CloudCRUD,
UI: CloudUI, UI: CloudUI,

View File

@@ -0,0 +1,5 @@
import { atom } from 'jotai/vanilla';
export const appHeaderAtom = atom<HTMLDivElement | null>(null);
export const mainContainerAtom = atom<HTMLDivElement | null>(null);

View File

@@ -49,7 +49,7 @@ export const fontStyleOptions = [
}[]; }[];
const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', { const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
clientBorder: globalThis.platform !== 'win32', clientBorder: environment.isDesktop && globalThis.platform !== 'win32',
fullWidthLayout: false, fullWidthLayout: false,
windowFrameStyle: 'frameless', windowFrameStyle: 'frameless',
fontStyle: 'Sans', fontStyle: 'Sans',

View File

@@ -11,7 +11,11 @@ export const AppContainer = (props: WorkspaceRootProps) => {
return ( return (
<AppContainerWithoutSettings <AppContainerWithoutSettings
useNoisyBackground={appSettings.enableNoisyBackground} useNoisyBackground={appSettings.enableNoisyBackground}
useBlurBackground={!appSettings.enableBlurBackground} useBlurBackground={
appSettings.enableBlurBackground &&
environment.isDesktop &&
environment.isMacOs
}
{...props} {...props}
/> />
); );

View File

@@ -1,23 +1,35 @@
import { import {
AuthContent, AuthContent,
BackButton, BackButton,
CountDownRender,
ModalHeader, ModalHeader,
ResendButton,
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { signIn } from 'next-auth/react'; import { Button } from '@toeverything/components/button';
import { type FC, useCallback } from 'react'; import { useCallback } from 'react';
import { buildCallbackUrl } from './callback-url'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
import * as style from './style.css'; import * as style from './style.css';
import { useAuth } from './use-auth';
export const AfterSignInSendEmail: FC<AuthPanelProps> = ({ export const AfterSignInSendEmail = ({
setAuthState, setAuthState,
email, email,
}) => { onSignedIn,
}: AuthPanelProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const { resendCountDown, allowSendEmail, signIn } = useAuth();
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
const onResendClick = useCallback(async () => {
await signIn(email);
}, [email, signIn]);
return ( return (
<> <>
@@ -31,15 +43,23 @@ export const AfterSignInSendEmail: FC<AuthPanelProps> = ({
{t['com.affine.auth.sign.sent.email.message.end']()} {t['com.affine.auth.sign.sent.email.message.end']()}
</AuthContent> </AuthContent>
<ResendButton <div className={style.resendWrapper}>
onClick={useCallback(() => { {allowSendEmail ? (
signIn('email', { <Button type="plain" size="large" onClick={onResendClick}>
email, {t['com.affine.auth.sign.auth.code.resend.hint']()}
callbackUrl: buildCallbackUrl('signIn'), </Button>
redirect: true, ) : (
}).catch(console.error); <>
}, [email])} <span className="resend-code-hint">
/> {t['com.affine.auth.sign.auth.code.on.resend.hint']()}
</span>
<CountDownRender
className={style.resendCountdown}
timeLeft={resendCountDown}
/>
</>
)}
</div>
<div className={style.authMessage} style={{ marginTop: 20 }}> <div className={style.authMessage} style={{ marginTop: 20 }}>
{/*prettier-ignore*/} {/*prettier-ignore*/}

View File

@@ -1,22 +1,35 @@
import { import {
AuthContent, AuthContent,
BackButton, BackButton,
CountDownRender,
ModalHeader, ModalHeader,
ResendButton,
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { signIn } from 'next-auth/react'; import { Button } from '@toeverything/components/button';
import { type FC, useCallback } from 'react'; import { type FC, useCallback } from 'react';
import { buildCallbackUrl } from './callback-url'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
import * as style from './style.css'; import * as style from './style.css';
import { useAuth } from './use-auth';
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
setAuthState, setAuthState,
email, email,
onSignedIn,
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const { resendCountDown, allowSendEmail, signUp } = useAuth();
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
const onResendClick = useCallback(async () => {
await signUp(email);
}, [email, signUp]);
return ( return (
<> <>
@@ -30,15 +43,23 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
{t['com.affine.auth.sign.sent.email.message.end']()} {t['com.affine.auth.sign.sent.email.message.end']()}
</AuthContent> </AuthContent>
<ResendButton <div className={style.resendWrapper}>
onClick={useCallback(() => { {allowSendEmail ? (
signIn('email', { <Button type="plain" size="large" onClick={onResendClick}>
email: email, {t['com.affine.auth.sign.auth.code.resend.hint']()}
callbackUrl: buildCallbackUrl('signUp'), </Button>
redirect: true, ) : (
}).catch(console.error); <>
}, [email])} <span className="resend-code-hint">
/> {t['com.affine.auth.sign.auth.code.on.resend.hint']()}
</span>
<CountDownRender
className={style.resendCountdown}
timeLeft={resendCountDown}
/>
</>
)}
</div>
<div className={style.authMessage} style={{ marginTop: 20 }}> <div className={style.authMessage} style={{ marginTop: 20 }}>
{t['com.affine.auth.sign.auth.code.message']()} {t['com.affine.auth.sign.auth.code.message']()}

View File

@@ -1,16 +0,0 @@
import { isDesktop } from '@affine/env/constant';
type Action = 'signUp' | 'changePassword' | 'signIn' | 'signUp';
export function buildCallbackUrl(action: Action) {
const callbackUrl = `/auth/${action}`;
const params: string[][] = [];
if (isDesktop && window.appInfo.schema) {
params.push(['schema', window.appInfo.schema]);
}
const query =
params.length > 0
? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
: '';
return callbackUrl + query;
}

View File

@@ -2,11 +2,13 @@ import {
AuthModal as AuthModalBase, AuthModal as AuthModalBase,
type AuthModalProps as AuthModalBaseProps, type AuthModalProps as AuthModalBaseProps,
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { atom, useAtom } from 'jotai'; import { refreshRootMetadataAtom } from '@affine/workspace/atom';
import { type FC, useCallback, useEffect, useMemo } from 'react'; import { useSetAtom } from 'jotai';
import { type FC, startTransition, useCallback, useMemo } from 'react';
import { AfterSignInSendEmail } from './after-sign-in-send-email'; import { AfterSignInSendEmail } from './after-sign-in-send-email';
import { AfterSignUpSendEmail } from './after-sign-up-send-email'; import { AfterSignUpSendEmail } from './after-sign-up-send-email';
import { NoAccess } from './no-access';
import { SendEmail } from './send-email'; import { SendEmail } from './send-email';
import { SignIn } from './sign-in'; import { SignIn } from './sign-in';
import { SignInWithPassword } from './sign-in-with-password'; import { SignInWithPassword } from './sign-in-with-password';
@@ -18,7 +20,8 @@ export type AuthProps = {
| 'afterSignInSendEmail' | 'afterSignInSendEmail'
// throw away // throw away
| 'signInWithPassword' | 'signInWithPassword'
| 'sendEmail'; | 'sendEmail'
| 'noAccess';
setAuthState: (state: AuthProps['state']) => void; setAuthState: (state: AuthProps['state']) => void;
setAuthEmail: (state: AuthProps['email']) => void; setAuthEmail: (state: AuthProps['email']) => void;
setEmailType: (state: AuthProps['emailType']) => void; setEmailType: (state: AuthProps['emailType']) => void;
@@ -34,8 +37,6 @@ export type AuthPanelProps = {
setEmailType: AuthProps['setEmailType']; setEmailType: AuthProps['setEmailType'];
emailType: AuthProps['emailType']; emailType: AuthProps['emailType'];
onSignedIn?: () => void; onSignedIn?: () => void;
authStore: AuthStoreAtom;
setAuthStore: (data: Partial<AuthStoreAtom>) => void;
}; };
const config: { const config: {
@@ -46,17 +47,9 @@ const config: {
afterSignInSendEmail: AfterSignInSendEmail, afterSignInSendEmail: AfterSignInSendEmail,
signInWithPassword: SignInWithPassword, signInWithPassword: SignInWithPassword,
sendEmail: SendEmail, sendEmail: SendEmail,
noAccess: NoAccess,
}; };
type AuthStoreAtom = {
hasSentEmail: boolean;
resendCountDown: number;
};
export const authStoreAtom = atom<AuthStoreAtom>({
hasSentEmail: false,
resendCountDown: 60,
});
export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({ export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
open, open,
state, state,
@@ -67,21 +60,14 @@ export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
setEmailType, setEmailType,
emailType, emailType,
}) => { }) => {
const [, setAuthStore] = useAtom(authStoreAtom); const refreshMetadata = useSetAtom(refreshRootMetadataAtom);
useEffect(() => {
if (!open) {
setAuthStore({
hasSentEmail: false,
resendCountDown: 60,
});
setAuthEmail('');
}
}, [open, setAuthEmail, setAuthStore]);
const onSignedIn = useCallback(() => { const onSignedIn = useCallback(() => {
setOpen(false); setOpen(false);
}, [setOpen]); startTransition(() => {
refreshMetadata();
});
}, [refreshMetadata, setOpen]);
return ( return (
<AuthModalBase open={open} setOpen={setOpen}> <AuthModalBase open={open} setOpen={setOpen}>
@@ -107,39 +93,18 @@ export const AuthPanel: FC<AuthProps> = ({
emailType, emailType,
onSignedIn, onSignedIn,
}) => { }) => {
const [authStore, setAuthStore] = useAtom(authStoreAtom);
const CurrentPanel = useMemo(() => { const CurrentPanel = useMemo(() => {
return config[state]; return config[state];
}, [state]); }, [state]);
useEffect(() => {
return () => {
setAuthStore({
hasSentEmail: false,
resendCountDown: 60,
});
};
}, [setAuthEmail, setAuthStore]);
return ( return (
<CurrentPanel <CurrentPanel
email={email} email={email}
setAuthState={setAuthState} setAuthState={setAuthState}
setAuthEmail={setAuthEmail} setAuthEmail={setAuthEmail}
setEmailType={setEmailType} setEmailType={setEmailType}
authStore={authStore}
emailType={emailType} emailType={emailType}
onSignedIn={onSignedIn} onSignedIn={onSignedIn}
setAuthStore={useCallback(
(data: Partial<AuthStoreAtom>) => {
setAuthStore(prev => ({
...prev,
...data,
}));
},
[setAuthStore]
)}
/> />
); );
}; };

View File

@@ -0,0 +1,53 @@
import {
AuthContent,
BackButton,
ModalHeader,
} from '@affine/component/auth-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { NewIcon } from '@blocksuite/icons';
import { type FC, useCallback } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
export const NoAccess: FC<AuthPanelProps> = ({ setAuthState, onSignedIn }) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
return (
<>
<ModalHeader
title={t['AFFiNE Cloud']()}
subTitle={t['Early Access Stage']()}
/>
<AuthContent style={{ height: 162 }}>
{t['com.affine.auth.sign.no.access.hint']()}
<a href="https://community.affine.pro/c/insider-general/">
{t['com.affine.auth.sign.no.access.link']()}
</a>
</AuthContent>
<div className={style.accessMessage}>
<NewIcon
style={{
fontSize: 16,
marginRight: 4,
color: 'var(--affine-icon-color)',
}}
/>
{t['com.affine.auth.sign.no.access.wait']()}
</div>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
</>
);
};

View File

@@ -16,7 +16,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql'; import { useMutation } from '@affine/workspace/affine/gql';
import { Button } from '@toeverything/components/button'; import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai/react'; import { useSetAtom } from 'jotai/react';
import { type FC, useCallback } from 'react'; import { useCallback, useState } from 'react';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
@@ -118,14 +118,13 @@ const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
}; };
}; };
export const SendEmail: FC<AuthPanelProps> = ({ export const SendEmail = ({
setAuthState, setAuthState,
setAuthStore,
email, email,
authStore: { hasSentEmail },
emailType, emailType,
}) => { }: AuthPanelProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [hasSentEmail, setHasSentEmail] = useState(false);
const pushNotification = useSetAtom(pushNotificationAtom); const pushNotification = useSetAtom(pushNotificationAtom);
const title = useEmailTitle(emailType); const title = useEmailTitle(emailType);
@@ -143,8 +142,8 @@ export const SendEmail: FC<AuthPanelProps> = ({
key: Date.now().toString(), key: Date.now().toString(),
type: 'success', type: 'success',
}); });
setAuthStore({ hasSentEmail: true }); setHasSentEmail(true);
}, [email, hint, pushNotification, sendEmail, setAuthStore]); }, [email, hint, pushNotification, sendEmail]);
return ( return (
<> <>

View File

@@ -9,10 +9,11 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button'; import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { signIn, useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { signInCloud } from '../../../utils/cloud-utils';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
import { forgetPasswordButton } from './style.css'; import { forgetPasswordButton } from './style.css';
@@ -30,7 +31,7 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
const [passwordError, setPasswordError] = useState(false); const [passwordError, setPasswordError] = useState(false);
const onSignIn = useCallback(async () => { const onSignIn = useCallback(async () => {
const res = await signIn('credentials', { const res = await signInCloud('credentials', {
redirect: false, redirect: false,
email, email,
password, password,

View File

@@ -1,60 +1,54 @@
import { AuthInput, ModalHeader } from '@affine/component/auth-components'; import {
import { pushNotificationAtom } from '@affine/component/notification-center'; AuthInput,
import type { Notification } from '@affine/component/notification-center/index.jotai'; CountDownRender,
ModalHeader,
} from '@affine/component/auth-components';
import { getUserQuery } from '@affine/graphql'; import { getUserQuery } from '@affine/graphql';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql'; import { useMutation } from '@affine/workspace/affine/gql';
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons'; import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button'; import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import { signIn, type SignInResponse } from 'next-auth/react';
import { type FC, useState } from 'react'; import { type FC, useState } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { emailRegex } from '../../../utils/email-regex'; import { emailRegex } from '../../../utils/email-regex';
import { buildCallbackUrl } from './callback-url';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
import * as style from './style.css'; import * as style from './style.css';
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
function validateEmail(email: string) { function validateEmail(email: string) {
return emailRegex.test(email); return emailRegex.test(email);
} }
const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
function handleSendEmailError(
res: SignInResponse | undefined,
pushNotification: (notification: Notification) => void
) {
if (res?.error) {
pushNotification({
title: 'Send email error',
message: 'Please back to home and try again',
type: 'error',
});
}
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
pushNotification({
title: 'Sign up error',
message: `You don't have early access permission\nVisit ${INTERNAL_BETA_URL} for more information`,
type: 'error',
});
}
}
export const SignIn: FC<AuthPanelProps> = ({ export const SignIn: FC<AuthPanelProps> = ({
setAuthState, setAuthState,
setAuthEmail, setAuthEmail,
email, email,
onSignedIn,
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const {
isMutating: isSigningIn,
resendCountDown,
allowSendEmail,
signIn,
signUp,
signInWithGoogle,
} = useAuth();
const { trigger: verifyUser, isMutating } = useMutation({ const { trigger: verifyUser, isMutating } = useMutation({
mutation: getUserQuery, mutation: getUserQuery,
}); });
const [isValidEmail, setIsValidEmail] = useState(true); const [isValidEmail, setIsValidEmail] = useState(true);
const pushNotification = useSetAtom(pushNotificationAtom);
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
const onContinue = useCallback(async () => { const onContinue = useCallback(async () => {
if (!validateEmail(email)) { if (!validateEmail(email)) {
setIsValidEmail(false); setIsValidEmail(false);
@@ -63,29 +57,23 @@ export const SignIn: FC<AuthPanelProps> = ({
setIsValidEmail(true); setIsValidEmail(true);
const { user } = await verifyUser({ email }); const { user } = await verifyUser({ email });
setAuthEmail(email); setAuthEmail(email);
if (user) { if (user) {
signIn('email', { const res = await signIn(email);
email: email, if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
callbackUrl: buildCallbackUrl('signIn'), return setAuthState('noAccess');
redirect: false, }
})
.then(res => handleSendEmailError(res, pushNotification))
.catch(console.error);
setAuthState('afterSignInSendEmail'); setAuthState('afterSignInSendEmail');
} else { } else {
signIn('email', { const res = await signUp(email);
email: email, if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
callbackUrl: buildCallbackUrl('signUp'), return setAuthState('noAccess');
redirect: false, }
})
.then(res => handleSendEmailError(res, pushNotification))
.catch(console.error);
setAuthState('afterSignUpSendEmail'); setAuthState('afterSignUpSendEmail');
} }
}, [email, setAuthEmail, setAuthState, verifyUser, pushNotification]); }, [email, setAuthEmail, setAuthState, signIn, signUp, verifyUser]);
return ( return (
<> <>
<ModalHeader <ModalHeader
@@ -102,8 +90,8 @@ export const SignIn: FC<AuthPanelProps> = ({
}} }}
icon={<GoogleDuotoneIcon />} icon={<GoogleDuotoneIcon />}
onClick={useCallback(() => { onClick={useCallback(() => {
signIn('google').catch(console.error); signInWithGoogle();
}, [])} }, [signInWithGoogle])}
> >
{t['Continue with Google']()} {t['Continue with Google']()}
</Button> </Button>
@@ -130,16 +118,24 @@ export const SignIn: FC<AuthPanelProps> = ({
size="extraLarge" size="extraLarge"
data-testid="continue-login-button" data-testid="continue-login-button"
block block
loading={isMutating} loading={isMutating || isSigningIn}
disabled={!allowSendEmail}
icon={ icon={
<ArrowDownBigIcon allowSendEmail || isMutating ? (
width={20} <ArrowDownBigIcon
height={20} width={20}
style={{ height={20}
transform: 'rotate(-90deg)', style={{
color: 'var(--affine-blue)', transform: 'rotate(-90deg)',
}} color: 'var(--affine-blue)',
/> }}
/>
) : (
<CountDownRender
className={style.resendCountdownInButton}
timeLeft={resendCountDown}
/>
)
} }
iconPosition="end" iconPosition="end"
onClick={onContinue} onClick={onContinue}

View File

@@ -26,3 +26,32 @@ export const forgetPasswordButton = style({
bottom: 0, bottom: 0,
display: 'none', display: 'none',
}); });
export const resendWrapper = style({
height: 32,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: 30,
});
export const resendCountdown = style({ width: 45, textAlign: 'center' });
export const resendCountdownInButton = style({
width: 40,
textAlign: 'center',
fontSize: 'var(--affine-font-sm)',
marginLeft: 16,
color: 'var(--affine-blue)',
fontWeight: 400,
});
export const accessMessage = style({
textAlign: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: 'var(--affine-font-xs)',
fontWeight: 500,
marginTop: 65,
marginBottom: 40,
});

View File

@@ -0,0 +1,137 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import type { Notification } from '@affine/component/notification-center/index.jotai';
import { atom, useAtom, useSetAtom } from 'jotai';
import { type SignInResponse } from 'next-auth/react';
import { useCallback } from 'react';
import { signInCloud } from '../../../utils/cloud-utils';
const COUNT_DOWN_TIME = 60;
export const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
function handleSendEmailError(
res: SignInResponse | undefined | void,
pushNotification: (notification: Notification) => void
) {
if (res?.error) {
pushNotification({
title: 'Send email error',
message: 'Please back to home and try again',
type: 'error',
});
}
}
type AuthStoreAtom = {
allowSendEmail: boolean;
resendCountDown: number;
isMutating: boolean;
};
export const authStoreAtom = atom<AuthStoreAtom>({
isMutating: false,
allowSendEmail: true,
resendCountDown: COUNT_DOWN_TIME,
});
const countDownAtom = atom(
null, // it's a convention to pass `null` for the first argument
(get, set) => {
const clearId = window.setInterval(() => {
const countDown = get(authStoreAtom).resendCountDown;
if (countDown === 0) {
set(authStoreAtom, {
isMutating: false,
allowSendEmail: true,
resendCountDown: COUNT_DOWN_TIME,
});
window.clearInterval(clearId);
return;
}
set(authStoreAtom, {
isMutating: false,
resendCountDown: countDown - 1,
allowSendEmail: false,
});
}, 1000);
}
);
export const useAuth = () => {
const pushNotification = useSetAtom(pushNotificationAtom);
const [authStore, setAuthStore] = useAtom(authStoreAtom);
const startResendCountDown = useSetAtom(countDownAtom);
const signIn = useCallback(
async (email: string) => {
setAuthStore(prev => {
return {
...prev,
isMutating: true,
};
});
const res = await signInCloud('email', {
email: email,
callbackUrl: '/auth/signIn',
redirect: false,
}).catch(console.error);
handleSendEmailError(res, pushNotification);
setAuthStore({
isMutating: false,
allowSendEmail: false,
resendCountDown: COUNT_DOWN_TIME,
});
startResendCountDown();
return res;
},
[pushNotification, setAuthStore, startResendCountDown]
);
const signUp = useCallback(
async (email: string) => {
setAuthStore(prev => {
return {
...prev,
isMutating: true,
};
});
const res = await signInCloud('email', {
email: email,
callbackUrl: '/auth/signUp',
redirect: false,
}).catch(console.error);
handleSendEmailError(res, pushNotification);
setAuthStore({
isMutating: false,
allowSendEmail: false,
resendCountDown: COUNT_DOWN_TIME,
});
startResendCountDown();
return res;
},
[pushNotification, setAuthStore, startResendCountDown]
);
const signInWithGoogle = useCallback(() => {
signInCloud('google').catch(console.error);
}, []);
return {
allowSendEmail: authStore.allowSendEmail,
resendCountDown: authStore.resendCountDown,
isMutating: authStore.isMutating,
signUp,
signIn,
signInWithGoogle,
};
};

View File

@@ -5,11 +5,10 @@ import {
MenuTrigger, MenuTrigger,
styled, styled,
} from '@affine/component'; } from '@affine/component';
import { LOCALES } from '@affine/i18n'; import { LOCALES, useI18N } from '@affine/i18n';
import { useI18N } from '@affine/i18n'; import { assertExists } from '@blocksuite/global/utils';
import type { ButtonProps } from '@toeverything/components/button'; import type { ButtonProps } from '@toeverything/components/button';
import type { ReactElement } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
export const StyledListItem = styled(MenuItem)(() => ({ export const StyledListItem = styled(MenuItem)(() => ({
height: '38px', height: '38px',
@@ -17,30 +16,78 @@ export const StyledListItem = styled(MenuItem)(() => ({
})); }));
interface LanguageMenuContentProps { interface LanguageMenuContentProps {
currentLanguage?: string; currentLanguage: string;
currentLanguageIndex: number;
} }
const LanguageMenuContent = ({ currentLanguage }: LanguageMenuContentProps) => { const LanguageMenuContent = ({
currentLanguage,
currentLanguageIndex,
}: LanguageMenuContentProps) => {
const i18n = useI18N(); const i18n = useI18N();
const changeLanguage = useCallback( const changeLanguage = useCallback(
(event: string) => { (targetLanguage: string) => {
return i18n.changeLanguage(event); console.assert(
LOCALES.some(item => item.tag === targetLanguage),
'targetLanguage should be one of the LOCALES'
);
i18n.changeLanguage(targetLanguage).catch(err => {
console.error('Failed to change language', err);
});
}, },
[i18n] [i18n]
); );
const [focusedOptionIndex, setFocusedOptionIndex] = useState(
currentLanguageIndex ?? 0
);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
setFocusedOptionIndex(prevIndex =>
prevIndex > 0 ? prevIndex - 1 : 0
);
break;
case 'ArrowDown':
event.preventDefault();
setFocusedOptionIndex(prevIndex =>
prevIndex < LOCALES.length - 1 ? prevIndex + 1 : LOCALES.length
);
break;
case 'Enter':
if (focusedOptionIndex !== -1) {
const selectedOption = LOCALES[focusedOptionIndex];
changeLanguage(selectedOption.tag);
}
break;
default:
break;
}
},
[changeLanguage, focusedOptionIndex]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
return ( return (
<> <>
{LOCALES.map(option => { {LOCALES.map((option, optionIndex) => {
return ( return (
<StyledListItem <StyledListItem
key={option.name} key={option.name}
active={currentLanguage === option.originalName} active={option.tag === currentLanguage}
userFocused={optionIndex == focusedOptionIndex}
title={option.name} title={option.name}
onClick={() => { onClick={() => {
changeLanguage(option.tag).catch(err => { changeLanguage(option.tag);
throw new Error('Failed to change language', err);
});
}} }}
> >
{option.originalName} {option.originalName}
@@ -61,16 +108,19 @@ export const LanguageMenu = ({
}: LanguageMenuProps) => { }: LanguageMenuProps) => {
const i18n = useI18N(); const i18n = useI18N();
const currentLanguage = LOCALES.find(item => item.tag === i18n.language); const currentLanguageIndex = LOCALES.findIndex(
item => item.tag === i18n.language
);
const currentLanguage = LOCALES[currentLanguageIndex];
assertExists(currentLanguage, 'currentLanguage should exist');
return ( return (
<Menu <Menu
content={ content={
( <LanguageMenuContent
<LanguageMenuContent currentLanguage={currentLanguage.tag}
currentLanguage={currentLanguage?.originalName} currentLanguageIndex={currentLanguageIndex}
/> />
) as ReactElement
} }
placement="bottom-end" placement="bottom-end"
trigger="click" trigger="click"
@@ -82,7 +132,7 @@ export const LanguageMenu = ({
style={{ textTransform: 'capitalize' }} style={{ textTransform: 'capitalize' }}
{...triggerProps} {...triggerProps}
> >
{currentLanguage?.originalName} {currentLanguage.originalName}
</MenuTrigger> </MenuTrigger>
</Menu> </Menu>
); );

View File

@@ -14,6 +14,7 @@ import { useMemo } from 'react';
import { useWorkspace } from '../../../hooks/use-workspace'; import { useWorkspace } from '../../../hooks/use-workspace';
import { DeleteLeaveWorkspace } from './delete-leave-workspace'; import { DeleteLeaveWorkspace } from './delete-leave-workspace';
import { ExportPanel } from './export'; import { ExportPanel } from './export';
import { LabelsPanel } from './labels';
import { MembersPanel } from './members'; import { MembersPanel } from './members';
import { ProfilePanel } from './profile'; import { ProfilePanel } from './profile';
import { PublishPanel } from './publish'; import { PublishPanel } from './publish';
@@ -69,6 +70,7 @@ export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
spreadCol={false} spreadCol={false}
> >
<ProfilePanel workspace={workspace} {...props} /> <ProfilePanel workspace={workspace} {...props} />
<LabelsPanel workspace={workspace} {...props} />
</SettingRow> </SettingRow>
</SettingWrapper> </SettingWrapper>
<SettingWrapper title={t['AFFiNE Cloud']()}> <SettingWrapper title={t['AFFiNE Cloud']()}>

View File

@@ -0,0 +1,103 @@
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { useMemo } from 'react';
import { type WorkspaceSettingDetailProps } from './index';
import * as style from './style.css';
export interface LabelsPanelProps extends WorkspaceSettingDetailProps {
workspace: AffineOfficialWorkspace;
}
type WorkspaceStatus =
| 'local'
| 'syncCloud'
| 'syncDocker'
| 'selfHosted'
| 'joinedWorkspace'
| 'availableOffline'
| 'publishedToWeb';
type LabelProps = {
value: string;
background: string;
};
type LabelMap = {
[key in WorkspaceStatus]: LabelProps;
};
type labelConditionsProps = {
condition: boolean;
label: WorkspaceStatus;
};
const Label = ({ value, background }: LabelProps) => {
return (
<div>
<div className={style.workspaceLabel} style={{ background: background }}>
{value}
</div>
</div>
);
};
export const LabelsPanel = ({ workspace, isOwner }: LabelsPanelProps) => {
const labelMap: LabelMap = useMemo(
() => ({
local: {
value: 'Local',
background: 'var(--affine-tag-orange)',
},
syncCloud: {
value: 'Sync with AFFiNE Cloud',
background: 'var(--affine-tag-blue)',
},
syncDocker: {
value: 'Sync with AFFiNE Docker',
background: 'var(--affine-tag-green)',
},
selfHosted: {
value: 'Self-Hosted Server',
background: 'var(--affine-tag-purple)',
},
joinedWorkspace: {
value: 'Joined Workspace',
background: 'var(--affine-tag-yellow)',
},
availableOffline: {
value: 'Available Offline',
background: 'var(--affine-tag-green)',
},
publishedToWeb: {
value: 'Published to Web',
background: 'var(--affine-tag-blue)',
},
}),
[]
);
const labelConditions: labelConditionsProps[] = [
{ condition: !isOwner, label: 'joinedWorkspace' },
{ condition: workspace.flavour === 'local', label: 'local' },
{ condition: workspace.flavour === 'affine-cloud', label: 'syncCloud' },
{
condition: workspace.flavour === 'affine-public',
label: 'publishedToWeb',
},
//TODO: add these labels
// { status==="synced", label: 'availableOffline' }
// { workspace.flavour === 'affine-Docker', label: 'syncDocker' }
// { workspace.flavour === 'self-hosted', label: 'selfHosted' }
];
return (
<div className={style.labelWrapper}>
{labelConditions.map(
({ condition, label }) =>
condition && (
<Label
key={label}
value={labelMap[label].value}
background={labelMap[label].background}
/>
)
)}
</div>
);
};

View File

@@ -12,6 +12,15 @@ export const profileHandlerWrapper = style({
marginLeft: '20px', marginLeft: '20px',
}); });
export const labelWrapper = style({
width: '100%',
display: 'flex',
alignItems: 'center',
marginTop: '24px',
gap: '10px',
flexWrap: 'wrap',
});
export const avatarWrapper = style({ export const avatarWrapper = style({
width: '56px', width: '56px',
height: '56px', height: '56px',
@@ -146,3 +155,17 @@ export const label = style({
color: 'var(--affine-text-secondary-color)', color: 'var(--affine-text-secondary-color)',
marginBottom: '5px', marginBottom: '5px',
}); });
export const workspaceLabel = style({
width: '100%',
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '6px',
padding: '2px 10px',
border: '1px solid var(--affine-white-30)',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-primary-color)',
lineHeight: '20px',
whiteSpace: 'nowrap',
});

View File

@@ -2,19 +2,20 @@ import { FlexWrapper, Input } from '@affine/component';
import { import {
SettingHeader, SettingHeader,
SettingRow, SettingRow,
StorageProgress,
} from '@affine/component/setting-components'; } from '@affine/component/setting-components';
import { UserAvatar } from '@affine/component/user-avatar'; import { UserAvatar } from '@affine/component/user-avatar';
import { uploadAvatarMutation } from '@affine/graphql'; import { allBlobSizesQuery, uploadAvatarMutation } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql'; import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon, CameraIcon, DoneIcon } from '@blocksuite/icons'; import { ArrowRightSmallIcon, CameraIcon, DoneIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button'; import { Button, IconButton } from '@toeverything/components/button';
import { useAtom } from 'jotai/index'; import { useSetAtom } from 'jotai';
import { signOut } from 'next-auth/react'; import { type FC, Suspense, useCallback, useState } from 'react';
import { type FC, useCallback, useState } from 'react';
import { authAtom } from '../../../../atoms'; import { authAtom } from '../../../../atoms';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user'; import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { signOutCloud } from '../../../../utils/cloud-utils';
import { Upload } from '../../../pure/file-upload'; import { Upload } from '../../../pure/file-upload';
import * as style from './style.css'; import * as style from './style.css';
@@ -109,10 +110,34 @@ export const AvatarAndName = () => {
); );
}; };
const StoragePanel = () => {
const t = useAFFiNEI18N();
const { data } = useQuery({
query: allBlobSizesQuery,
});
const onUpgrade = useCallback(() => {}, []);
return (
<SettingRow
name={t['com.affine.storage.title']()}
desc=""
spreadCol={false}
>
<StorageProgress
max={10737418240}
value={data.collectAllBlobSizes.size}
onUpgrade={onUpgrade}
/>
</SettingRow>
);
};
export const AccountSetting: FC = () => { export const AccountSetting: FC = () => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const user = useCurrentUser(); const user = useCurrentUser();
const [, setAuthModal] = useAtom(authAtom); const setAuthModal = useSetAtom(authAtom);
const onChangeEmail = useCallback(() => { const onChangeEmail = useCallback(() => {
setAuthModal({ setAuthModal({
@@ -122,14 +147,15 @@ export const AccountSetting: FC = () => {
emailType: 'changeEmail', emailType: 'changeEmail',
}); });
}, [setAuthModal, user.email]); }, [setAuthModal, user.email]);
const onChangePassword = useCallback(() => {
const onPasswordButtonClick = useCallback(() => {
setAuthModal({ setAuthModal({
openModal: true, openModal: true,
state: 'sendEmail', state: 'sendEmail',
email: user.email, email: user.email,
emailType: 'changePassword', emailType: user.hasPassword ? 'changePassword' : 'setPassword',
}); });
}, [setAuthModal, user.email]); }, [setAuthModal, user.email, user.hasPassword]);
return ( return (
<> <>
@@ -148,19 +174,21 @@ export const AccountSetting: FC = () => {
name={t['com.affine.settings.password']()} name={t['com.affine.settings.password']()}
desc={t['com.affine.settings.password.message']()} desc={t['com.affine.settings.password.message']()}
> >
<Button onClick={onChangePassword}> <Button onClick={onPasswordButtonClick}>
{user.hasPassword {user.hasPassword
? t['com.affine.settings.password.action.change']() ? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()} : t['com.affine.settings.password.action.set']()}
</Button> </Button>
</SettingRow> </SettingRow>
<Suspense>
<StoragePanel />
</Suspense>
<SettingRow <SettingRow
name={t[`Sign out`]()} name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()} desc={t['com.affine.setting.sign.out.message']()}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={useCallback(() => { onClick={useCallback(() => {
signOut().catch(console.error); signOutCloud().catch(console.error);
}, [])} }, [])}
> >
<ArrowRightSmallIcon /> <ArrowRightSmallIcon />

View File

@@ -212,17 +212,19 @@ export const AppearanceSettings = () => {
} }
/> />
</SettingRow> </SettingRow>
<SettingRow {environment.isMacOs && (
name={t['com.affine.settings.translucent-style']()} <SettingRow
desc={t['com.affine.settings.translucent-style-description']()} name={t['com.affine.settings.translucent-style']()}
> desc={t['com.affine.settings.translucent-style-description']()}
<Switch >
checked={appSettings.enableBlurBackground} <Switch
onChange={checked => checked={appSettings.enableBlurBackground}
changeSwitch('enableBlurBackground', checked) onChange={checked =>
} changeSwitch('enableBlurBackground', checked)
/> }
</SettingRow> />
</SettingRow>
)}
</SettingWrapper> </SettingWrapper>
) : null} ) : null}
</> </>

View File

@@ -24,11 +24,8 @@ export const shortcutKey = style({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: '0 6px', padding: '0 6px',
border: '1px solid var(--affine-border-color)',
borderRadius: '4px', borderRadius: '4px',
background: 'var(--affine-background-tertiary-color)', background: 'var(--affine-background-tertiary-color)',
boxShadow:
'0px 6px 4px 0px rgba(255, 255, 255, 0.24) inset, 0px 0px 0px 0.5px rgba(0, 0, 0, 0.10) inset',
fontSize: 'var(--affine-font-xs)', fontSize: 'var(--affine-font-xs)',
selectors: { selectors: {
'&:not(:last-of-type)': { '&:not(:last-of-type)': {

View File

@@ -50,7 +50,11 @@ export const UserInfo = ({
}: UserInfoProps): ReactElement => { }: UserInfoProps): ReactElement => {
const user = useCurrentUser(); const user = useCurrentUser();
return ( return (
<div className={accountButton} onClick={onAccountSettingClick}> <div
data-testid="user-info-card"
className={accountButton}
onClick={onAccountSettingClick}
>
<UserAvatar <UserAvatar
size={28} size={28}
name={user.name} name={user.name}
@@ -59,10 +63,10 @@ export const UserInfo = ({
/> />
<div className="content"> <div className="content">
<div className="name" title="xxx"> <div className="name" title={user.name}>
{user.name} {user.name}
</div> </div>
<div className="email" title="xxx"> <div className="email" title={user.email}>
{user.email} {user.email}
</div> </div>
</div> </div>

View File

@@ -123,8 +123,8 @@ globalStyle(`${accountButton} .avatar.not-sign`, {
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderColor: 'var(--affine-border-color)', borderColor: 'var(--affine-icon-secondary)',
color: 'var(--affine-border-color)', color: 'var(--affine-icon-secondary)',
background: 'var(--affine-white)', background: 'var(--affine-white)',
}); });
globalStyle(`${accountButton} .content`, { globalStyle(`${accountButton} .content`, {

View File

@@ -8,10 +8,9 @@ export const settingContent = style({
}); });
globalStyle(`${settingContent} .wrapper`, { globalStyle(`${settingContent} .wrapper`, {
width: '60%',
padding: '0 15px', padding: '0 15px',
height: '100%', height: '100%',
minWidth: '560px', maxWidth: '560px',
margin: '0 auto', margin: '0 auto',
overflowY: 'auto', overflowY: 'auto',
}); });

View File

@@ -171,7 +171,9 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
/> />
</> </>
); );
if (pageMeta.trash) {
return null;
}
return ( return (
<> <>
<FlexWrapper alignItems="center" justifyContent="center"> <FlexWrapper alignItems="center" justifyContent="center">

View File

@@ -25,6 +25,15 @@ export const titleInput = style({
margin: 'auto', margin: 'auto',
width: '100%', width: '100%',
height: '100%', height: '100%',
selectors: {
'&:focus': {
border: '1px solid var(--affine-black-10)',
borderRadius: '8px',
height: '32px',
padding: '6px 8px',
},
},
}); });
export const shadowTitle = style({ export const shadowTitle = style({
visibility: 'hidden', visibility: 'hidden',

View File

@@ -1,10 +1,10 @@
import { UserAvatar } from '@affine/component/user-avatar'; import { UserAvatar } from '@affine/component/user-avatar';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon } from '@blocksuite/icons'; import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { signIn } from 'next-auth/react';
import { useCurrentLoginStatus } from '../../hooks/affine/use-current-login-status'; import { useCurrentLoginStatus } from '../../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../../hooks/affine/use-current-user'; import { useCurrentUser } from '../../hooks/affine/use-current-user';
import { signInCloud } from '../../utils/cloud-utils';
import { StyledSignInButton } from '../pure/footer/styles'; import { StyledSignInButton } from '../pure/footer/styles';
export const LoginCard = () => { export const LoginCard = () => {
@@ -17,8 +17,7 @@ export const LoginCard = () => {
<StyledSignInButton <StyledSignInButton
data-testid="sign-in-button" data-testid="sign-in-button"
onClick={async () => { onClick={async () => {
// jump to login page signInCloud().catch(console.error);
signIn().catch(console.error);
}} }}
> >
<div className="circle"> <div className="circle">

View File

@@ -1,12 +1,12 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon } from '@blocksuite/icons'; import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { signIn } from 'next-auth/react';
import { type CSSProperties, type FC, forwardRef, useCallback } from 'react'; import { type CSSProperties, type FC, forwardRef, useCallback } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
// import { openDisableCloudAlertModalAtom } from '../../../atoms';
import { stringToColour } from '../../../utils'; import { stringToColour } from '../../../utils';
import { signInCloud } from '../../../utils/cloud-utils';
import { StyledFooter, StyledSignInButton } from './styles'; import { StyledFooter, StyledSignInButton } from './styles';
export const Footer: FC = () => { export const Footer: FC = () => {
const loginStatus = useCurrentLoginStatus(); const loginStatus = useCurrentLoginStatus();
@@ -25,7 +25,7 @@ const SignInButton = () => {
<StyledSignInButton <StyledSignInButton
data-testid="sign-in-button" data-testid="sign-in-button"
onClick={useCallback(() => { onClick={useCallback(() => {
signIn().catch(console.error); signInCloud().catch(console.error);
}, [])} }, [])}
> >
<div className="circle"> <div className="circle">

View File

@@ -1,116 +1,43 @@
import { Wrapper } from '@affine/component';
import { import {
appSidebarFloatingAtom, appSidebarFloatingAtom,
appSidebarOpenAtom, appSidebarOpenAtom,
SidebarSwitch, SidebarSwitch,
} from '@affine/component/app-sidebar'; } from '@affine/component/app-sidebar';
import { isDesktop } from '@affine/env/constant'; import { isDesktop } from '@affine/env/constant';
import { useIsTinyScreen } from '@toeverything/hooks/use-is-tiny-screen';
import clsx from 'clsx'; import clsx from 'clsx';
import { useAtomValue } from 'jotai'; import { type Atom, useAtomValue } from 'jotai';
import debounce from 'lodash.debounce'; import type { ReactElement } from 'react';
import type { MutableRefObject, ReactNode } from 'react'; import { forwardRef, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import * as style from './style.css'; import * as style from './style.css';
import { TopTip } from './top-tip'; import { TopTip } from './top-tip';
import { WindowsAppControls } from './windows-app-controls'; import { WindowsAppControls } from './windows-app-controls';
interface HeaderPros { interface HeaderPros {
left?: ReactNode; left?: ReactElement;
right?: ReactNode; right?: ReactElement;
center?: ReactNode; center?: ReactElement;
mainContainerAtom: Atom<HTMLDivElement | null>;
} }
const useIsTinyScreen = ({
mainContainer,
leftStatic,
leftSlot,
centerDom,
rightStatic,
rightSlot,
}: {
mainContainer: HTMLElement;
leftStatic: MutableRefObject<HTMLElement | null>;
leftSlot: MutableRefObject<HTMLElement | null>[];
centerDom: MutableRefObject<HTMLElement | null>;
rightStatic: MutableRefObject<HTMLElement | null>;
rightSlot: MutableRefObject<HTMLElement | null>[];
}) => {
const [isTinyScreen, setIsTinyScreen] = useState(false);
useEffect(() => {
const handleResize = debounce(() => {
if (!centerDom.current) {
return;
}
const leftStaticWidth = leftStatic.current?.clientWidth || 0;
const leftSlotWidth = leftSlot.reduce((accWidth, dom) => {
return accWidth + (dom.current?.clientWidth || 0);
}, 0);
const rightStaticWidth = rightStatic.current?.clientWidth || 0;
const rightSlotWidth = rightSlot.reduce((accWidth, dom) => {
return accWidth + (dom.current?.clientWidth || 0);
}, 0);
if (!leftSlotWidth && !rightSlotWidth) {
if (isTinyScreen) {
setIsTinyScreen(false);
}
return;
}
const containerRect = mainContainer.getBoundingClientRect();
const centerRect = centerDom.current.getBoundingClientRect();
if (
leftStaticWidth + leftSlotWidth + containerRect.left >=
centerRect.left ||
containerRect.right - centerRect.right <=
rightSlotWidth + rightStaticWidth
) {
setIsTinyScreen(true);
} else {
setIsTinyScreen(false);
}
}, 100);
handleResize();
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(mainContainer);
return () => {
resizeObserver.disconnect();
};
}, [
centerDom,
isTinyScreen,
leftSlot,
leftStatic,
mainContainer,
rightSlot,
rightStatic,
]);
return isTinyScreen;
};
// The Header component is used to solve the following problems // The Header component is used to solve the following problems
// 1. Manage layout issues independently of page or business logic // 1. Manage layout issues independently of page or business logic
// 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed // 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed
export const Header = ({ left, center, right }: HeaderPros) => { export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
{ left, center, right, mainContainerAtom },
ref
) {
const sidebarSwitchRef = useRef<HTMLDivElement | null>(null); const sidebarSwitchRef = useRef<HTMLDivElement | null>(null);
const leftSlotRef = useRef<HTMLDivElement | null>(null); const leftSlotRef = useRef<HTMLDivElement | null>(null);
const centerSlotRef = useRef<HTMLDivElement | null>(null); const centerSlotRef = useRef<HTMLDivElement | null>(null);
const rightSlotRef = useRef<HTMLDivElement | null>(null); const rightSlotRef = useRef<HTMLDivElement | null>(null);
const windowControlsRef = useRef<HTMLDivElement | null>(null); const windowControlsRef = useRef<HTMLDivElement | null>(null);
const mainContainer = useAtomValue(mainContainerAtom);
const isTinyScreen = useIsTinyScreen({ const isTinyScreen = useIsTinyScreen({
mainContainer: document.querySelector('.main-container') || document.body, mainContainer,
leftStatic: sidebarSwitchRef, leftStatic: sidebarSwitchRef,
leftSlot: [leftSlotRef], leftSlot: [leftSlotRef],
centerDom: centerSlotRef, centerDom: centerSlotRef,
@@ -130,6 +57,7 @@ export const Header = ({ left, center, right }: HeaderPros) => {
data-open={open} data-open={open}
data-sidebar-floating={appSidebarFloating} data-sidebar-floating={appSidebarFloating}
data-testid="header" data-testid="header"
ref={ref}
> >
<div <div
className={clsx(style.headerSideContainer, { className={clsx(style.headerSideContainer, {
@@ -137,12 +65,8 @@ export const Header = ({ left, center, right }: HeaderPros) => {
})} })}
> >
<div className={clsx(style.headerItem, 'top-item')}> <div className={clsx(style.headerItem, 'top-item')}>
<div ref={sidebarSwitchRef}> <div ref={sidebarSwitchRef} style={{ marginRight: open ? 0 : 20 }}>
{!open && ( <SidebarSwitch show={!open} />
<Wrapper marginRight={20}>
<SidebarSwitch />
</Wrapper>
)}
</div> </div>
</div> </div>
<div className={clsx(style.headerItem, 'left')}> <div className={clsx(style.headerItem, 'left')}>
@@ -175,4 +99,6 @@ export const Header = ({ left, center, right }: HeaderPros) => {
</div> </div>
</> </>
); );
}; });
Header.displayName = 'Header';

View File

@@ -7,6 +7,7 @@ export const header = style({
position: 'relative', position: 'relative',
padding: '0 16px', padding: '0 16px',
minHeight: '52px', minHeight: '52px',
background: 'var(--affine-background-primary-color)',
borderBottom: '1px solid var(--affine-border-color)', borderBottom: '1px solid var(--affine-border-color)',
zIndex: 2, zIndex: 2,
selectors: { selectors: {

View File

@@ -35,6 +35,7 @@ export const TrashButtonGroup = () => {
<div className={group}> <div className={group}>
<div className={buttonContainer}> <div className={buttonContainer}>
<Button <Button
data-testid="page-restore-button"
type="primary" type="primary"
onClick={() => { onClick={() => {
restoreFromTrash(pageId); restoreFromTrash(pageId);

View File

@@ -110,9 +110,9 @@ const CloudWorkSpaceList = ({
<> <>
<StyledModalHeader> <StyledModalHeader>
<StyledModalHeaderLeft> <StyledModalHeaderLeft>
<StyledModalTitle> <StyledWorkspaceFlavourTitle>
{t['com.affine.workspace.cloud']()} {t['com.affine.workspace.cloud']()}
</StyledModalTitle> </StyledWorkspaceFlavourTitle>
</StyledModalHeaderLeft> </StyledModalHeaderLeft>
</StyledModalHeader> </StyledModalHeader>
<StyledModalContent> <StyledModalContent>

View File

@@ -236,9 +236,8 @@ export const StyledModalBody = styled('div')(() => {
export const StyledWorkspaceFlavourTitle = styled('div')(() => { export const StyledWorkspaceFlavourTitle = styled('div')(() => {
return { return {
fontSize: '12px', fontSize: 'var(--affine-font-xs)',
fontWeight: 600,
color: 'var(--affine-text-secondary-color)', color: 'var(--affine-text-secondary-color)',
lineHeight: '20px', marginBottom: '4px',
}; };
}); });

View File

@@ -1,4 +1,20 @@
import { style } from '@vanilla-extract/css'; import { createVar, keyframes, style } from '@vanilla-extract/css';
export const workspaceAvatarStyle = style({ export const workspaceAvatarStyle = style({
flexShrink: 0, flexShrink: 0,
}); });
export const speedVar = createVar('speedVar');
const rotate = keyframes({
'0%': { transform: 'rotate(0deg)' },
'50%': { transform: 'rotate(180deg)' },
'100%': { transform: 'rotate(360deg)' },
});
export const loading = style({
vars: {
[speedVar]: '1.5s',
},
textRendering: 'optimizeLegibility',
WebkitFontSmoothing: 'antialiased',
animation: `${rotate} ${speedVar} infinite linear`,
});

View File

@@ -0,0 +1,35 @@
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { loading, speedVar } from './index.css';
export type LoadingProps = {
size?: number;
speed?: number;
};
export const Loading = ({ size, speed = 1.2 }: LoadingProps) => {
return (
<svg
className={loading}
width={size ? `${size}px` : '16px'}
height={size ? `${size}px` : '16px'}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{
...assignInlineVars({
[speedVar]: `${speed}s`,
}),
}}
>
<path
d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM4.95017 12C4.95017 15.8935 8.10648 19.0498 12 19.0498C15.8935 19.0498 19.0498 15.8935 19.0498 12C19.0498 8.10648 15.8935 4.95017 12 4.95017C8.10648 4.95017 4.95017 8.10648 4.95017 12Z"
fill="var(--affine-black-10)"
/>
<path
d="M20.525 12C21.3396 12 22.0111 11.3361 21.8914 10.5303C21.7714 9.72269 21.5527 8.93094 21.2388 8.17317C20.7362 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C15.0691 2.44732 14.2773 2.22859 13.4697 2.10859C12.6639 1.98886 12 2.66038 12 3.475C12 4.28962 12.6674 4.93455 13.4643 5.10374C13.8853 5.19314 14.2983 5.32113 14.6979 5.48665C15.5533 5.84095 16.3304 6.36024 16.9851 7.0149C17.6398 7.66955 18.1591 8.44674 18.5133 9.30208C18.6789 9.70167 18.8069 10.1147 18.8963 10.5357C19.0655 11.3326 19.7104 12 20.525 12Z"
fill="var(--affine-primary-color)"
/>
</svg>
);
};

View File

@@ -1,6 +1,10 @@
import { displayFlex, textEllipsis } from '@affine/component'; import { displayFlex, textEllipsis } from '@affine/component';
import { styled } from '@affine/component'; import { styled } from '@affine/component';
export const StyledSelectorContainer = styled('div')(() => { export const StyledSelectorContainer = styled('div')(({
disableHoverBackground,
}: {
disableHoverBackground: boolean;
}) => {
return { return {
height: '58px', height: '58px',
display: 'flex', display: 'flex',
@@ -10,7 +14,7 @@ export const StyledSelectorContainer = styled('div')(() => {
color: 'var(--affine-text-primary-color)', color: 'var(--affine-text-primary-color)',
':hover': { ':hover': {
cursor: 'pointer', cursor: 'pointer',
background: 'var(--affine-hover-color)', background: disableHoverBackground ? '' : 'var(--affine-hover-color)',
}, },
}; };
}); });
@@ -38,10 +42,17 @@ export const StyledWorkspaceStatus = styled('div')(() => {
fontSize: 'var(--affine-font-sm)', fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)', color: 'var(--affine-text-secondary-color)',
userSelect: 'none', userSelect: 'none',
padding: '0 4px',
gap: '4px',
zIndex: '1',
svg: { svg: {
color: 'var(--affine-icon-color)', color: 'var(--affine-icon-color)',
fontSize: 'var(--affine-font-base)', fontSize: 'var(--affine-font-base)',
marginRight: '4px', },
':hover': {
cursor: 'pointer',
borderRadius: '4px',
background: 'var(--affine-hover-color)',
}, },
}; };
}); });

View File

@@ -1,11 +1,27 @@
import { WorkspaceAvatar } from '@affine/component/workspace-avatar'; import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { CloudWorkspaceIcon, LocalWorkspaceIcon } from '@blocksuite/icons'; import { WorkspaceFlavour } from '@affine/env/workspace';
import {
CloudWorkspaceIcon,
LocalWorkspaceIcon,
NoNetworkIcon,
UnsyncIcon,
} from '@blocksuite/icons';
import { Tooltip } from '@toeverything/components/tooltip';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import type React from 'react'; import { atom, useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react'; import {
type KeyboardEvent,
type MouseEvent,
useCallback,
useMemo,
useState,
} from 'react';
import { useDatasourceSync } from '../../../../hooks/use-datasource-sync';
import { useSystemOnline } from '../../../../hooks/use-system-online';
import type { AllWorkspace } from '../../../../shared'; import type { AllWorkspace } from '../../../../shared';
import { workspaceAvatarStyle } from './index.css'; import { workspaceAvatarStyle } from './index.css';
import { Loading } from './loading-icon';
import { import {
StyledSelectorContainer, StyledSelectorContainer,
StyledSelectorWrapper, StyledSelectorWrapper,
@@ -18,6 +34,133 @@ export interface WorkspaceSelectorProps {
onClick: () => void; onClick: () => void;
} }
const hoverAtom = atom(false);
const CloudWorkspaceStatus = () => {
return (
<>
<CloudWorkspaceIcon />
AFFiNE Cloud
</>
);
};
const SyncingWorkspaceStatus = () => {
return (
<>
<Loading />
Syncing...
</>
);
};
const UnSyncWorkspaceStatus = () => {
return (
<>
<UnsyncIcon />
Wait for upload
</>
);
};
const LocalWorkspaceStatus = () => {
return (
<>
<LocalWorkspaceIcon />
Local
</>
);
};
const OfflineStatus = () => {
return (
<>
<NoNetworkIcon />
Offline
</>
);
};
const WorkspaceStatus = ({
currentWorkspace,
}: {
currentWorkspace: AllWorkspace;
}) => {
const isOnline = useSystemOnline();
// todo: finish display sync status
const [forceSyncStatus, startForceSync] = useDatasourceSync(
currentWorkspace.blockSuiteWorkspace
);
const setIsHovered = useSetAtom(hoverAtom);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const content = useMemo(() => {
if (currentWorkspace.flavour === WorkspaceFlavour.LOCAL) {
return 'Saved locally';
}
if (!isOnline) {
return 'Disconnected, please check your network connection';
}
switch (forceSyncStatus.type) {
case 'syncing':
return 'Syncing with AFFiNE Cloud';
case 'error':
return 'Sync failed due to server issues, please try again later.';
default:
return 'Sync with AFFiNE Cloud';
}
}, [currentWorkspace.flavour, forceSyncStatus.type, isOnline]);
const CloudWorkspaceSyncStatus = useCallback(() => {
if (forceSyncStatus.type === 'syncing') {
return SyncingWorkspaceStatus();
} else if (forceSyncStatus.type === 'error') {
return UnSyncWorkspaceStatus();
} else {
return CloudWorkspaceStatus();
}
}, [forceSyncStatus.type]);
const handleClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (
currentWorkspace.flavour === WorkspaceFlavour.LOCAL ||
forceSyncStatus.type === 'syncing'
) {
return;
}
startForceSync();
},
[currentWorkspace.flavour, forceSyncStatus.type, startForceSync]
);
return (
<div style={{ display: 'flex' }}>
<Tooltip
content={content}
portalOptions={{
container,
}}
>
<StyledWorkspaceStatus
onMouseEnter={() => {
setIsHovered(true);
}}
ref={setContainer}
onMouseLeave={() => setIsHovered(false)}
onClick={handleClick}
>
{currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
!isOnline ? (
<OfflineStatus />
) : (
<CloudWorkspaceSyncStatus />
)
) : (
<LocalWorkspaceStatus />
)}
</StyledWorkspaceStatus>
</Tooltip>
</div>
);
};
/** /**
* @todo-Doma Co-locate WorkspaceListModal with {@link WorkspaceSelector}, * @todo-Doma Co-locate WorkspaceListModal with {@link WorkspaceSelector},
* because it's never used elsewhere. * because it's never used elsewhere.
@@ -29,12 +172,11 @@ export const WorkspaceSelector = ({
const [name] = useBlockSuiteWorkspaceName( const [name] = useBlockSuiteWorkspaceName(
currentWorkspace.blockSuiteWorkspace currentWorkspace.blockSuiteWorkspace
); );
// Open dialog when `Enter` or `Space` pressed // Open dialog when `Enter` or `Space` pressed
// TODO-Doma Refactor with `@radix-ui/react-dialog` or other libraries that handle these out of the box and be accessible by default // TODO-Doma Refactor with `@radix-ui/react-dialog` or other libraries that handle these out of the box and be accessible by default
// TODO: Delete this? // TODO: Delete this?
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
// TODO-Doma Rename this callback to `onOpenDialog` or something to reduce ambiguity. // TODO-Doma Rename this callback to `onOpenDialog` or something to reduce ambiguity.
@@ -43,6 +185,7 @@ export const WorkspaceSelector = ({
}, },
[onClick] [onClick]
); );
const isHovered = useAtomValue(hoverAtom);
return ( return (
<StyledSelectorContainer <StyledSelectorContainer
@@ -50,6 +193,7 @@ export const WorkspaceSelector = ({
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
disableHoverBackground={isHovered}
data-testid="current-workspace" data-testid="current-workspace"
id="current-workspace" id="current-workspace"
> >
@@ -63,14 +207,7 @@ export const WorkspaceSelector = ({
<StyledWorkspaceName data-testid="workspace-name"> <StyledWorkspaceName data-testid="workspace-name">
{name} {name}
</StyledWorkspaceName> </StyledWorkspaceName>
<StyledWorkspaceStatus> <WorkspaceStatus currentWorkspace={currentWorkspace} />
{currentWorkspace.flavour === 'local' ? (
<LocalWorkspaceIcon />
) : (
<CloudWorkspaceIcon />
)}
{currentWorkspace.flavour === 'local' ? 'Local' : 'AFFiNE Cloud'}
</StyledWorkspaceStatus>
</StyledSelectorWrapper> </StyledSelectorWrapper>
</StyledSelectorContainer> </StyledSelectorContainer>
); );

View File

@@ -51,7 +51,7 @@ export const AddCollectionButton = ({
onConfirm={setting.saveCollection} onConfirm={setting.saveCollection}
open={show} open={show}
onClose={() => showUpdateCollection(false)} onClose={() => showUpdateCollection(false)}
title={t['Save As New Collection']()} title={t['Save as New Collection']()}
init={defaultCollection} init={defaultCollection}
/> />
</> </>

View File

@@ -77,10 +77,10 @@ export const collapsibleContent = style({
marginTop: '4px', marginTop: '4px',
selectors: { selectors: {
'&[data-state="open"]': { '&[data-state="open"]': {
animation: `${slideDown} 0.2s ease-out`, animation: `${slideDown} 0.2s ease-in-out`,
}, },
'&[data-state="closed"]': { '&[data-state="closed"]': {
animation: `${slideUp} 0.2s ease-out`, animation: `${slideUp} 0.2s ease-in-out`,
}, },
}, },
}); });

View File

@@ -155,7 +155,13 @@ export const RootAppSidebar = ({
<> <>
<AppSidebar <AppSidebar
router={router} router={router}
hasBackground={!appSettings.enableBlurBackground} hasBackground={
!(
appSettings.enableBlurBackground &&
environment.isDesktop &&
environment.isMacOs
)
}
> >
<SidebarContainer> <SidebarContainer>
<NoSsr> <NoSsr>

View File

@@ -4,6 +4,7 @@ import {
SaveCollectionButton, SaveCollectionButton,
useCollectionManager, useCollectionManager,
} from '@affine/component/page-list'; } from '@affine/component/page-list';
import { Unreachable } from '@affine/env/constant';
import type { Collection } from '@affine/env/filter'; import type { Collection } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter'; import type { PropertiesMeta } from '@affine/env/filter';
import { import {
@@ -11,8 +12,10 @@ import {
type WorkspaceHeaderProps, type WorkspaceHeaderProps,
} from '@affine/env/workspace'; } from '@affine/env/workspace';
import { WorkspaceSubPath } from '@affine/env/workspace'; import { WorkspaceSubPath } from '@affine/env/workspace';
import { useSetAtom } from 'jotai/react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { appHeaderAtom, mainContainerAtom } from '../atoms/element';
import { useGetPageInfoById } from '../hooks/use-get-page-info'; import { useGetPageInfoById } from '../hooks/use-get-page-info';
import { useWorkspace } from '../hooks/use-workspace'; import { useWorkspace } from '../hooks/use-workspace';
import { SharePageModal } from './affine/share-page-modal'; import { SharePageModal } from './affine/share-page-modal';
@@ -76,6 +79,7 @@ export function WorkspaceHeader({
currentEntry, currentEntry,
}: WorkspaceHeaderProps<WorkspaceFlavour>) { }: WorkspaceHeaderProps<WorkspaceFlavour>) {
const setting = useCollectionManager(currentWorkspaceId); const setting = useCollectionManager(currentWorkspaceId);
const setAppHeader = useSetAtom(appHeaderAtom);
const currentWorkspace = useWorkspace(currentWorkspaceId); const currentWorkspace = useWorkspace(currentWorkspaceId);
const getPageInfoById = useGetPageInfoById( const getPageInfoById = useGetPageInfoById(
@@ -90,6 +94,8 @@ export function WorkspaceHeader({
return ( return (
<> <>
<Header <Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
left={ left={
<CollectionList <CollectionList
setting={setting} setting={setting}
@@ -112,7 +118,13 @@ export function WorkspaceHeader({
(currentEntry.subPath === WorkspaceSubPath.SHARED || (currentEntry.subPath === WorkspaceSubPath.SHARED ||
currentEntry.subPath === WorkspaceSubPath.TRASH) currentEntry.subPath === WorkspaceSubPath.TRASH)
) { ) {
return <Header center={<WorkspaceModeFilterTab />} />; return (
<Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
center={<WorkspaceModeFilterTab />}
/>
);
} }
// route in edit page // route in edit page
@@ -128,6 +140,8 @@ export function WorkspaceHeader({
) : null; ) : null;
return ( return (
<Header <Header
mainContainerAtom={mainContainerAtom}
ref={setAppHeader}
center={ center={
<BlockSuiteHeaderTitle <BlockSuiteHeaderTitle
workspace={currentWorkspace} workspace={currentWorkspace}
@@ -144,5 +158,5 @@ export function WorkspaceHeader({
); );
} }
return null; throw new Unreachable();
} }

View File

@@ -0,0 +1,91 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import type {
AffineSocketIOProvider,
LocalIndexedDBBackgroundProvider,
SQLiteProvider,
} from '@affine/env/workspace';
import { type Status, syncDataSource } from '@affine/y-provider';
import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { useSetAtom } from 'jotai';
import { startTransition, useCallback, useMemo, useState } from 'react';
export function useDatasourceSync(workspace: Workspace) {
const [status, setStatus] = useState<Status>({
type: 'idle',
});
const pushNotification = useSetAtom(pushNotificationAtom);
const providers = workspace.providers;
const remoteProvider: AffineSocketIOProvider | undefined = useMemo(() => {
return providers.find(
(provider): provider is AffineSocketIOProvider =>
provider.flavour === 'affine-socket-io'
);
}, [providers]);
const localProvider = useMemo(() => {
const sqliteProvider = providers.find(
(provider): provider is SQLiteProvider => provider.flavour === 'sqlite'
);
const indexedDbProvider = providers.find(
(provider): provider is LocalIndexedDBBackgroundProvider =>
provider.flavour === 'local-indexeddb-background'
);
const provider = sqliteProvider || indexedDbProvider;
assertExists(provider, 'no local provider');
return provider;
}, [providers]);
return [
status,
useCallback(() => {
if (!remoteProvider) {
return;
}
startTransition(() => {
setStatus({
type: 'syncing',
});
});
syncDataSource(
() => [
workspace.doc.guid,
...[...workspace.doc.subdocs].map(doc => doc.guid),
],
remoteProvider.datasource,
localProvider.datasource
)
.then(async () => {
// by default, the syncing status will show for 2.4s
setTimeout(() => {
startTransition(() => {
setStatus({
type: 'synced',
});
pushNotification({
title: 'Synced successfully',
type: 'success',
});
});
}, 2400);
})
.catch(error => {
startTransition(() => {
setStatus({
type: 'error',
error,
});
pushNotification({
title: 'Unable to Sync',
message: 'Server error, please try again later.',
type: 'error',
});
});
});
}, [
remoteProvider,
localProvider.datasource,
workspace.doc.guid,
workspace.doc.subdocs,
pushNotification,
]),
] as const;
}

View File

@@ -33,14 +33,16 @@ import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/reac
import { currentWorkspaceIdAtom } from '@toeverything/infra/atom'; import { currentWorkspaceIdAtom } from '@toeverything/infra/atom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { PropsWithChildren, ReactElement } from 'react'; import type { PropsWithChildren, ReactElement } from 'react';
import { lazy, Suspense, useCallback } from 'react'; import { lazy, Suspense, useCallback, useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { Map as YMap } from 'yjs';
import { import {
openQuickSearchModalAtom, openQuickSearchModalAtom,
openSettingModalAtom, openSettingModalAtom,
openWorkspacesModalAtom, openWorkspacesModalAtom,
} from '../atoms'; } from '../atoms';
import { mainContainerAtom } from '../atoms/element';
import { useAppSetting } from '../atoms/settings'; import { useAppSetting } from '../atoms/settings';
import { AdapterProviderWrapper } from '../components/adapter-worksapce-wrapper'; import { AdapterProviderWrapper } from '../components/adapter-worksapce-wrapper';
import { AppContainer } from '../components/affine/app-container'; import { AppContainer } from '../components/affine/app-container';
@@ -133,6 +135,26 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
const [currentWorkspace] = useCurrentWorkspace(); const [currentWorkspace] = useCurrentWorkspace();
const { openPage } = useNavigateHelper(); const { openPage } = useNavigateHelper();
useEffect(() => {
// hotfix for blockVersions
// this is a mistake in the
// 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 blockVersions = meta.get('blockVersions');
if (
!(blockVersions instanceof YMap) &&
blockVersions != null &&
typeof blockVersions === 'object'
) {
meta.set(
'blockVersions',
new YMap(Object.entries(blockVersions as Record<string, number>))
);
}
}, [currentWorkspace.blockSuiteWorkspace.doc]);
usePassiveWorkspaceEffect(currentWorkspace.blockSuiteWorkspace); usePassiveWorkspaceEffect(currentWorkspace.blockSuiteWorkspace);
const [, setOpenWorkspacesModal] = useAtom(openWorkspacesModalAtom); const [, setOpenWorkspacesModal] = useAtom(openWorkspacesModalAtom);
@@ -206,6 +228,8 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
const location = useLocation(); const location = useLocation();
const { pageId } = useParams(); const { pageId } = useParams();
const setMainContainer = useSetAtom(mainContainerAtom);
return ( return (
<> <>
{/* This DndContext is used for drag page from all-pages list into a folder in sidebar */} {/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
@@ -234,8 +258,11 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
paths={pathGenerator} paths={pathGenerator}
/> />
</Suspense> </Suspense>
<Suspense fallback={<MainContainer />}> <Suspense fallback={<MainContainer ref={setMainContainer} />}>
<MainContainer padding={appSetting.clientBorder}> <MainContainer
ref={setMainContainer}
padding={appSetting.clientBorder}
>
{children} {children}
<ToolContainer> <ToolContainer>
<BlockHubWrapper blockHubAtom={rootBlockHubAtom} /> <BlockHubWrapper blockHubAtom={rootBlockHubAtom} />

View File

@@ -9,7 +9,12 @@ import { changeEmailMutation, changePasswordMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql'; import { useMutation } from '@affine/workspace/affine/gql';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { type LoaderFunction, redirect, useParams } from 'react-router-dom'; import {
type LoaderFunction,
redirect,
useParams,
useSearchParams,
} from 'react-router-dom';
import { z } from 'zod'; import { z } from 'zod';
import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status'; import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
@@ -27,6 +32,7 @@ const authTypeSchema = z.enum([
export const AuthPage = (): ReactElement | null => { export const AuthPage = (): ReactElement | null => {
const user = useCurrentUser(); const user = useCurrentUser();
const { authType } = useParams(); const { authType } = useParams();
const [searchParams] = useSearchParams();
const { trigger: changePassword } = useMutation({ const { trigger: changePassword } = useMutation({
mutation: changePasswordMutation, mutation: changePasswordMutation,
}); });
@@ -39,22 +45,22 @@ export const AuthPage = (): ReactElement | null => {
const onChangeEmail = useCallback( const onChangeEmail = useCallback(
async (email: string) => { async (email: string) => {
const res = await changeEmail({ const res = await changeEmail({
id: user.id, token: searchParams.get('token') || '',
newEmail: email, newEmail: email,
}); });
return !!res?.changeEmail; return !!res?.changeEmail;
}, },
[changeEmail, user.id] [changeEmail, searchParams]
); );
const onSetPassword = useCallback( const onSetPassword = useCallback(
(password: string) => { (password: string) => {
changePassword({ changePassword({
id: user.id, token: searchParams.get('token') || '',
newPassword: password, newPassword: password,
}).catch(console.error); }).catch(console.error);
}, },
[changePassword, user.id] [changePassword, searchParams]
); );
const onOpenAffine = useCallback(() => { const onOpenAffine = useCallback(() => {
jumpToIndex(RouteLogic.REPLACE); jumpToIndex(RouteLogic.REPLACE);

View File

@@ -0,0 +1,28 @@
import type { LoaderFunction } from 'react-router-dom';
import { z } from 'zod';
import { signInCloud } from '../utils/cloud-utils';
const supportedProvider = z.enum(['google']);
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) {
return null;
}
const maybeProvider = supportedProvider.safeParse(provider);
if (maybeProvider.success) {
const provider = maybeProvider.data;
await signInCloud(provider, {
callbackUrl: callback_url,
});
}
return null;
};
export const Component = () => {
return null;
};

View File

@@ -23,6 +23,7 @@ export const loader: LoaderFunction = async () => {
const target = (lastId && meta.find(({ id }) => id === lastId)) || meta.at(0); const target = (lastId && meta.find(({ id }) => id === lastId)) || meta.at(0);
if (target) { if (target) {
const targetWorkspace = getWorkspace(target.id); const targetWorkspace = getWorkspace(target.id);
const nonTrashPages = targetWorkspace.meta.pageMetas.filter( const nonTrashPages = targetWorkspace.meta.pageMetas.filter(
({ trash }) => !trash ({ trash }) => !trash
); );

View File

@@ -1,9 +1,15 @@
import { type GetCurrentUserQuery, getCurrentUserQuery } from '@affine/graphql';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { fetcher } from '@affine/workspace/affine/gql';
import { Logo1Icon } from '@blocksuite/icons'; import { Logo1Icon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button'; import { Button } from '@toeverything/components/button';
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import {
type LoaderFunction,
useLoaderData,
useSearchParams,
} from 'react-router-dom';
import { z } from 'zod'; import { z } from 'zod';
import * as styles from './open-app.css'; import * as styles from './open-app.css';
@@ -45,107 +51,164 @@ const appNames = {
internal: 'AFFiNE Internal', internal: 'AFFiNE Internal',
} satisfies Record<Channel, string>; } satisfies Record<Channel, string>;
export const Component = () => { interface OpenAppProps {
urlToOpen?: string | null;
channel: Channel;
}
interface LoaderData {
action: 'url' | 'oauth-jwt';
currentUser?: GetCurrentUserQuery['currentUser'];
}
const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const openDownloadLink = useCallback(() => {
const url = `https://affine.pro/download?channel=${channel}`;
open(url, '_blank');
}, [channel]);
const appIcon = appIconMap[channel];
const appName = appNames[channel];
const [params] = useSearchParams();
const autoOpen = useMemo(() => params.get('open') !== 'false', [params]);
useEffect(() => {
if (!urlToOpen || lastOpened === urlToOpen || !autoOpen) {
return;
}
setTimeout(() => {
lastOpened = urlToOpen;
open(urlToOpen, '_blank');
}, 1000);
}, [urlToOpen, autoOpen]);
if (!urlToOpen) {
return null;
}
return (
<div className={styles.root}>
<div className={styles.topNav}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.affineLogo}
>
<Logo1Icon width={24} height={24} />
</a>
<div className={styles.topNavLinks}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Official Website
</a>
<a
href="https://community.affine.pro/home"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
AFFiNE Community
</a>
<a
href="https://affine.pro/blog"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Blog
</a>
<a
href="https://affine.pro/about-us"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Contact us
</a>
</div>
<Button onClick={openDownloadLink}>
{t['com.affine.auth.open.affine.download-app']()}
</Button>
</div>
<div className={styles.centerContent}>
<img src={appIcon} alt={appName} width={120} height={120} />
<div className={styles.prompt}>
<Trans i18nKey="com.affine.auth.open.affine.prompt">
Open {appName} app now
</Trans>
</div>
<a
className={styles.tryAgainLink}
href={urlToOpen}
target="_blank"
rel="noreferrer"
>
{t['com.affine.auth.open.affine.try-again']()}
</a>
</div>
</div>
);
};
const OpenUrl = () => {
const [params] = useSearchParams(); const [params] = useSearchParams();
const urlToOpen = useMemo(() => params.get('url'), [params]); const urlToOpen = useMemo(() => params.get('url'), [params]);
const autoOpen = useMemo(() => params.get('open') !== 'false', [params]);
const channel = useMemo(() => { const channel = useMemo(() => {
const urlObj = new URL(urlToOpen || ''); const urlObj = new URL(urlToOpen || '');
const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', '')); const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
return schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine']; return schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
}, [urlToOpen]); }, [urlToOpen]);
const appIcon = appIconMap[channel]; return <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
const appName = appNames[channel]; };
const openDownloadLink = useCallback(() => { const OpenOAuthJwt = () => {
const url = `https://affine.pro/download?channel=${channel}`; const { currentUser } = useLoaderData() as LoaderData;
open(url, '_blank'); const [params] = useSearchParams();
}, [channel]); const schema = useMemo(() => {
const maybeSchema = appSchemas.safeParse(params.get('schema'));
return maybeSchema.success ? maybeSchema.data : 'affine';
}, [params]);
const channel = schemaToChanel[schema as Schema];
useEffect(() => { if (!currentUser || !currentUser?.token?.token) {
if (!urlToOpen || lastOpened === urlToOpen || !autoOpen) {
return;
}
lastOpened = urlToOpen;
open(urlToOpen, '_blank');
}, [urlToOpen, autoOpen]);
if (urlToOpen) {
return (
<div className={styles.root}>
<div className={styles.topNav}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.affineLogo}
>
<Logo1Icon width={24} height={24} />
</a>
<div className={styles.topNavLinks}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Official Website
</a>
<a
href="https://community.affine.pro/home"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
AFFiNE Community
</a>
<a
href="https://affine.pro/blog"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Blog
</a>
<a
href="https://affine.pro/about-us"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Contact us
</a>
</div>
<Button onClick={openDownloadLink}>
{t['com.affine.auth.open.affine.download-app']()}
</Button>
</div>
<div className={styles.centerContent}>
<img src={appIcon} alt={appName} width={120} height={120} />
<div className={styles.prompt}>
<Trans i18nKey="com.affine.auth.open.affine.prompt">
Open {appName} app now
</Trans>
</div>
<a
className={styles.tryAgainLink}
href={urlToOpen}
target="_blank"
rel="noreferrer"
>
{t['com.affine.auth.open.affine.try-again']()}
</a>
</div>
</div>
);
} else {
return null; return null;
} }
const urlToOpen = `${schema}://oauth-jwt?token=${currentUser.token.token}`;
return <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
};
export const Component = () => {
const { action } = useLoaderData() as LoaderData;
if (action === 'url') {
return <OpenUrl />;
} else if (action === 'oauth-jwt') {
return <OpenOAuthJwt />;
}
return null;
};
export const loader: LoaderFunction = async args => {
const action = args.params.action || '';
const res = await fetcher({
query: getCurrentUserQuery,
}).catch(console.error);
return {
action,
currentUser: res?.currentUser || null,
};
}; };

View File

@@ -164,7 +164,7 @@ export const DesktopLoginModal = (): ReactElement => {
useEffect(() => { useEffect(() => {
return window.events?.ui.onFinishLogin(({ success, email }) => { return window.events?.ui.onFinishLogin(({ success, email }) => {
if (email !== signingEmail) { if (email && email !== signingEmail) {
return; return;
} }
setSigningEmail(undefined); setSigningEmail(undefined);

View File

@@ -49,9 +49,13 @@ export const routes = [
lazy: () => import('./pages/sign-in'), lazy: () => import('./pages/sign-in'),
}, },
{ {
path: '/open-app', path: '/open-app/:action',
lazy: () => import('./pages/open-app'), lazy: () => import('./pages/open-app'),
}, },
{
path: '/desktop-signin',
lazy: () => import('./pages/desktop-signin'),
},
{ {
path: '*', path: '*',
lazy: () => import('./pages/404'), lazy: () => import('./pages/404'),

View File

@@ -0,0 +1,63 @@
import { isDesktop } from '@affine/env/constant';
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
import { getCurrentStore } from '@toeverything/infra/atom';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { signIn, signOut } from 'next-auth/react';
import { startTransition } from 'react';
export const signInCloud: typeof signIn = async (provider, ...rest) => {
if (isDesktop) {
if (provider === 'google') {
open(
`${
runtimeConfig.serverUrlPrefix
}/desktop-signin?provider=google&callback_url=${buildCallbackUrl(
'/open-app/oauth-jwt'
)}`,
'_target'
);
return;
} else {
const [options, ...tail] = rest;
const callbackUrl =
runtimeConfig.serverUrlPrefix +
(provider === 'email' ? '/open-app/oauth-jwt' : location.pathname);
return signIn(
provider,
{
...options,
callbackUrl: buildCallbackUrl(callbackUrl),
},
...tail
);
}
} else {
return signIn(provider, ...rest);
}
};
export const signOutCloud: typeof signOut = async options => {
return signOut({
...options,
callbackUrl: '/',
}).then(result => {
if (result) {
startTransition(() => {
getCurrentStore().set(refreshRootMetadataAtom);
});
}
return result;
});
};
export function buildCallbackUrl(callbackUrl: string) {
const params: string[][] = [];
if (isDesktop && window.appInfo.schema) {
params.push(['schema', window.appInfo.schema]);
}
const query =
params.length > 0
? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
: '';
return callbackUrl + query;
}

View File

@@ -1,13 +1,19 @@
import type { ToastOptions } from '@affine/component'; import type { ToastOptions } from '@affine/component';
import { toast as basicToast } from '@affine/component'; import { toast as basicToast } from '@affine/component';
import { assertEquals, assertExists } from '@blocksuite/global/utils';
import { getCurrentStore } from '@toeverything/infra/atom';
import { mainContainerAtom } from '../atoms/element';
export const toast = (message: string, options?: ToastOptions) => { export const toast = (message: string, options?: ToastOptions) => {
const mainContainer = getCurrentStore().get(mainContainerAtom);
const modal = document.querySelector( const modal = document.querySelector(
'[role=presentation]' '[role=presentation]'
) as HTMLElement | null; ) as HTMLDivElement | null;
const mainContainer = document.querySelector( assertExists(mainContainer, 'main container should exist');
'.main-container' if (modal) {
) as HTMLElement | null; assertEquals(modal.constructor, HTMLDivElement, 'modal should be div');
}
return basicToast(message, { return basicToast(message, {
portal: modal || mainContainer || document.body, portal: modal || mainContainer || document.body,
...options, ...options,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@affine/docs", "name": "@affine/docs",
"version": "0.9.0-canary.3", "version": "0.9.0-canary.8",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -10,14 +10,14 @@
}, },
"dependencies": { "dependencies": {
"@affine/component": "workspace:*", "@affine/component": "workspace:*",
"@blocksuite/block-std": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/block-std": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/blocks": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/blocks": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/editor": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/editor": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/global": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/global": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/lit": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/lit": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/store": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/store": "0.0.0-20230829150056-df43987c-nightly",
"express": "^4.18.2", "express": "^4.18.2",
"jotai": "^2.4.0", "jotai": "^2.4.1",
"react": "18.3.0-canary-7118f5dd7-20230705", "react": "18.3.0-canary-7118f5dd7-20230705",
"react-dom": "18.3.0-canary-7118f5dd7-20230705", "react-dom": "18.3.0-canary-7118f5dd7-20230705",
"react-server-dom-webpack": "18.3.0-canary-7118f5dd7-20230705", "react-server-dom-webpack": "18.3.0-canary-7118f5dd7-20230705",

View File

@@ -44,6 +44,7 @@ const makers = [
}, },
{ x: 432, y: 192, type: 'link', path: '/Applications' }, { x: 432, y: 192, type: 'link', path: '/Applications' },
], ],
iconSize: 118,
file: path.resolve( file: path.resolve(
__dirname, __dirname,
'out', 'out',
@@ -113,7 +114,6 @@ module.exports = {
: undefined, : undefined,
// We need the following line for updater // We need the following line for updater
extraResource: ['./resources/app-update.yml'], extraResource: ['./resources/app-update.yml'],
ignore: ['e2e', 'tests'],
protocols: [ protocols: [
{ {
name: productName, name: productName,

View File

@@ -1,7 +1,7 @@
{ {
"name": "@affine/electron", "name": "@affine/electron",
"private": true, "private": true,
"version": "0.9.0-canary.3", "version": "0.9.0-canary.8",
"author": "affine", "author": "affine",
"repository": { "repository": {
"url": "https://github.com/toeverything/AFFiNE", "url": "https://github.com/toeverything/AFFiNE",
@@ -29,10 +29,10 @@
"@affine/env": "workspace:*", "@affine/env": "workspace:*",
"@affine/native": "workspace:*", "@affine/native": "workspace:*",
"@affine/sdk": "workspace:*", "@affine/sdk": "workspace:*",
"@blocksuite/blocks": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/blocks": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/editor": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/editor": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/lit": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/lit": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/store": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/store": "0.0.0-20230829150056-df43987c-nightly",
"@electron-forge/cli": "^6.4.1", "@electron-forge/cli": "^6.4.1",
"@electron-forge/core": "^6.4.1", "@electron-forge/core": "^6.4.1",
"@electron-forge/core-utils": "^6.4.1", "@electron-forge/core-utils": "^6.4.1",
@@ -44,7 +44,7 @@
"@reforged/maker-appimage": "^3.3.1", "@reforged/maker-appimage": "^3.3.1",
"@toeverything/infra": "workspace:*", "@toeverything/infra": "workspace:*",
"@types/fs-extra": "^11.0.1", "@types/fs-extra": "^11.0.1",
"@types/uuid": "^9.0.2", "@types/uuid": "^9.0.3",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"electron": "^26.1.0", "electron": "^26.1.0",
"electron-log": "^5.0.0-beta.28", "electron-log": "^5.0.0-beta.28",
@@ -52,8 +52,8 @@
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"esbuild": "^0.19.2", "esbuild": "^0.19.2",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"glob": "^10.3.3", "glob": "^10.3.4",
"jotai": "^2.4.0", "jotai": "^2.4.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 761 KiB

View File

@@ -45,6 +45,22 @@ cd(repoRootDir);
if (!process.env.SKIP_WEB_BUILD) { if (!process.env.SKIP_WEB_BUILD) {
await $`yarn -T run build:plugins`; await $`yarn -T run build:plugins`;
await $`yarn nx build @affine/core`; await $`yarn nx build @affine/core`;
// step 1.5: amend sourceMappingURL to allow debugging in devtools
await glob('**/*.{js,css}', { cwd: affineCoreOutDir }).then(files => {
return files.map(async file => {
const dir = path.dirname(file);
const fullpath = path.join(affineCoreOutDir, file);
let content = await fs.readFile(fullpath, 'utf-8');
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => {
return `# sourceMappingURL=assets://./${dir}/${p1}.map`;
});
await fs.writeFile(fullpath, content);
});
});
await fs.move(affineCoreOutDir, publicAffineOutDir, { overwrite: true }); await fs.move(affineCoreOutDir, publicAffineOutDir, { overwrite: true });
} }

View File

@@ -1,5 +1,10 @@
import { type InsertRow, SqliteConnection } from '@affine/native'; import {
type InsertRow,
SqliteConnection,
ValidationResult,
} from '@affine/native';
import { migrateToLatestDatabase } from '../db/migration';
import { logger } from '../logger'; import { logger } from '../logger';
/** /**
@@ -13,6 +18,10 @@ export abstract class BaseSQLiteAdapter {
async connectIfNeeded() { async connectIfNeeded() {
if (!this.db) { if (!this.db) {
const validation = await SqliteConnection.validate(this.path);
if (validation === ValidationResult.MissingVersionColumn) {
await migrateToLatestDatabase(this.path);
}
this.db = new SqliteConnection(this.path); this.db = new SqliteConnection(this.path);
await this.db.connect(); await this.db.connect();
logger.info(`[SQLiteAdapter:${this.role}]`, 'connected:', this.path); logger.info(`[SQLiteAdapter:${this.role}]`, 'connected:', this.path);

View File

@@ -50,9 +50,11 @@ export const migrateToLatestDatabase = async (path: string) => {
const update = ( const update = (
await connection.getUpdates(isRoot ? undefined : doc.guid) await connection.getUpdates(isRoot ? undefined : doc.guid)
).map(update => update.data); ).map(update => update.data);
// Buffer[] -> Uint8Array // Buffer[] -> Uint8Array[]
const data = new Uint8Array(Buffer.concat(update).buffer); const data = update.map(update => new Uint8Array(update));
applyUpdate(doc, data); data.forEach(data => {
applyUpdate(doc, data);
});
// trigger data manually // trigger data manually
if (isRoot) { if (isRoot) {
doc.getMap('meta'); doc.getMap('meta');

View File

@@ -2,11 +2,13 @@ import path from 'node:path';
import type { App } from 'electron'; import type { App } from 'electron';
import { buildType, isDev } from './config'; import { buildType, CLOUD_BASE_URL, isDev } from './config';
import { logger } from './logger'; import { logger } from './logger';
import { import {
handleOpenUrlInHiddenWindow, handleOpenUrlInHiddenWindow,
mainWindowOrigin,
restoreOrCreateWindow, restoreOrCreateWindow,
setCookie,
} from './main-window'; } from './main-window';
import { uiSubjects } from './ui'; import { uiSubjects } from './ui';
@@ -57,47 +59,55 @@ async function handleAffineUrl(url: string) {
logger.info('handle affine schema action', urlObj.hostname); logger.info('handle affine schema action', urlObj.hostname);
// handle more actions here // handle more actions here
// hostname is the action name // hostname is the action name
if (urlObj.hostname === 'sign-in') { if (urlObj.hostname === 'oauth-jwt') {
const urlToOpen = urlObj.search.slice(1); await handleOauthJwt(url);
if (urlToOpen) {
await handleSignIn(urlToOpen);
}
} }
} }
// todo: move to another place? async function handleOauthJwt(url: string) {
async function handleSignIn(url: string) {
if (url) { if (url) {
try { try {
const mainWindow = await restoreOrCreateWindow(); const mainWindow = await restoreOrCreateWindow();
mainWindow.show(); mainWindow.show();
const urlObj = new URL(url); const urlObj = new URL(url);
const email = urlObj.searchParams.get('email'); const token = urlObj.searchParams.get('token');
if (!email) { if (!token) {
logger.error('no email in url', url); logger.error('no token in url', url);
return; return;
} }
uiSubjects.onStartLogin.next({ const isSecure = CLOUD_BASE_URL.startsWith('https://');
email,
// 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',
expirationDate: Math.floor(Date.now() / 1000 + 3600 * 24 * 7),
});
// force reset next-auth.callback-url
await setCookie({
url: CLOUD_BASE_URL,
httpOnly: true,
name: 'next-auth.callback-url',
});
// hacks to refresh auth state in the main window
const window = await handleOpenUrlInHiddenWindow(
mainWindowOrigin + '/auth/signIn'
);
uiSubjects.onFinishLogin.next({
success: true,
}); });
const window = await handleOpenUrlInHiddenWindow(url);
logger.info('opened url in hidden window', window.webContents.getURL());
// check path
// - if path === /auth/{signIn,signUp}, we know sign in succeeded
// - if path === expired, we know sign in failed
const finalUrl = new URL(window.webContents.getURL());
console.log('final url', finalUrl);
// hack: wait for the hidden window to send broadcast message to the main window
// that's how next-auth works for cross-tab communication
setTimeout(() => { setTimeout(() => {
window.destroy(); window.destroy();
}, 3000); }, 3000);
uiSubjects.onFinishLogin.next({
success: ['/auth/signIn', '/auth/signUp'].includes(finalUrl.pathname),
email,
});
} catch (e) { } catch (e) {
logger.error('failed to open url in popup', e); logger.error('failed to open url in popup', e);
} }

View File

@@ -1,11 +1,10 @@
import assert from 'node:assert'; import assert from 'node:assert';
import { BrowserWindow, nativeTheme } from 'electron'; import { BrowserWindow, type CookiesSetDetails, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state'; import electronWindowState from 'electron-window-state';
import { join } from 'path'; import { join } from 'path';
import { isMacOS, isWindows } from '../shared/utils'; import { isMacOS, isWindows } from '../shared/utils';
import { CLOUD_BASE_URL } from './config';
import { getExposedMeta } from './exposed'; import { getExposedMeta } from './exposed';
import { ensureHelperProcess } from './helper-process'; import { ensureHelperProcess } from './helper-process';
import { logger } from './logger'; import { logger } from './logger';
@@ -16,6 +15,8 @@ const IS_DEV: boolean =
const DEV_TOOL = process.env.DEV_TOOL === 'true'; const DEV_TOOL = process.env.DEV_TOOL === 'true';
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
async function createWindow() { async function createWindow() {
logger.info('create window'); logger.info('create window');
const mainWindowState = electronWindowState({ const mainWindowState = electronWindowState({
@@ -98,7 +99,6 @@ async function createWindow() {
// close and destroy all windows // close and destroy all windows
BrowserWindow.getAllWindows().forEach(w => { BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) { if (!w.isDestroyed()) {
w.close();
w.destroy(); w.destroy();
} }
}); });
@@ -116,7 +116,7 @@ async function createWindow() {
/** /**
* URL for main window. * URL for main window.
*/ */
const pageUrl = CLOUD_BASE_URL; // see protocol.ts const pageUrl = mainWindowOrigin; // see protocol.ts
logger.info('loading page at', pageUrl); logger.info('loading page at', pageUrl);
@@ -128,35 +128,30 @@ async function createWindow() {
} }
// singleton // singleton
let browserWindow: BrowserWindow | undefined; let browserWindow$: Promise<BrowserWindow> | undefined;
/** /**
* Restore existing BrowserWindow or Create new BrowserWindow * Restore existing BrowserWindow or Create new BrowserWindow
*/ */
export async function restoreOrCreateWindow() { export async function restoreOrCreateWindow() {
if (!browserWindow || browserWindow.isDestroyed()) { if (!browserWindow$ || (await browserWindow$.then(w => w.isDestroyed()))) {
browserWindow = await createWindow(); browserWindow$ = createWindow();
} }
const mainWindow = await browserWindow$;
if (browserWindow.isMinimized()) { if (mainWindow.isMinimized()) {
browserWindow.restore(); mainWindow.restore();
logger.info('restore main window'); logger.info('restore main window');
} }
return mainWindow;
return browserWindow;
} }
export async function handleOpenUrlInHiddenWindow(url: string) { export async function handleOpenUrlInHiddenWindow(url: string) {
const mainExposedMeta = getExposedMeta();
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
height: 600, height: 600,
webPreferences: { webPreferences: {
preload: join(__dirname, './preload.js'), preload: join(__dirname, './preload.js'),
additionalArguments: [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
// popup window does not need helper process, right?
],
}, },
show: false, show: false,
}); });
@@ -171,11 +166,33 @@ export async function handleOpenUrlInHiddenWindow(url: string) {
return win; return win;
} }
export function reloadApp() { export async function setCookie(cookie: CookiesSetDetails): Promise<void>;
browserWindow?.reload(); export async function setCookie(origin: string, cookie: string): Promise<void>;
export async function setCookie(
arg0: CookiesSetDetails | string,
arg1?: string
) {
const window = await restoreOrCreateWindow();
const details =
typeof arg1 === 'string' && typeof arg0 === 'string'
? parseCookie(arg0, arg1)
: arg0;
logger.info('setting cookie to main window', details);
if (typeof details !== 'object') {
throw new Error('invalid cookie details');
}
await window.webContents.session.cookies.set(details);
} }
export async function setCookie(origin: string, cookie: string) { export async function getCookie(url?: string, name?: string) {
const window = await restoreOrCreateWindow(); const window = await restoreOrCreateWindow();
await window.webContents.session.cookies.set(parseCookie(cookie, origin)); const cookies = await window.webContents.session.cookies.get({
url,
name,
});
return cookies;
} }

View File

@@ -2,8 +2,21 @@ import { net, protocol, session } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { CLOUD_BASE_URL } from './config'; import { CLOUD_BASE_URL } from './config';
import { setCookie } from './main-window'; import { logger } from './logger';
import { simpleGet } from './utils'; import { getCookie } from './main-window';
protocol.registerSchemesAsPrivileged([
{
scheme: 'assets',
privileges: {
secure: false,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
bypassCSP: true,
},
},
]);
const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql']; const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql'];
const webStaticDir = join(__dirname, '../resources/web-static'); const webStaticDir = join(__dirname, '../resources/web-static');
@@ -12,42 +25,20 @@ function isNetworkResource(pathname: string) {
return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt)); return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt));
} }
async function handleHttpRequest(request: Request) { async function handleFileRequest(request: Request) {
const clonedRequest = Object.assign(request.clone(), { const clonedRequest = Object.assign(request.clone(), {
bypassCustomProtocolHandlers: true, bypassCustomProtocolHandlers: true,
}); });
const { pathname, origin } = new URL(request.url); const urlObject = new URL(request.url);
if ( if (isNetworkResource(urlObject.pathname)) {
!origin.startsWith(CLOUD_BASE_URL) || // just pass through (proxy)
isNetworkResource(pathname) || return net.fetch(CLOUD_BASE_URL + urlObject.pathname, clonedRequest);
process.env.DEV_SERVER_URL // when debugging locally
) {
// note: I don't find a good way to get over with 302 redirect
// by default in net.fetch, or don't know if there is a way to
// bypass http request handling to browser instead ...
if (pathname.startsWith('/api/auth/callback')) {
const originResponse = await simpleGet(request.url);
// hack: use window.webContents.session.cookies to set cookies
// since return set-cookie header in response doesn't work here
for (const [, cookie] of originResponse.headers.filter(
p => p[0] === 'set-cookie'
)) {
await setCookie(origin, cookie);
}
return new Response(originResponse.body, {
headers: originResponse.headers,
status: originResponse.statusCode,
});
} else {
// just pass through (proxy)
return net.fetch(request.url, clonedRequest);
}
} else { } else {
// this will be file types (in the web-static folder) // this will be file types (in the web-static folder)
let filepath = ''; let filepath = '';
// if is a file type, load the file in resources // if is a file type, load the file in resources
if (pathname.split('/').at(-1)?.includes('.')) { if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
filepath = join(webStaticDir, decodeURIComponent(pathname)); filepath = join(webStaticDir, decodeURIComponent(urlObject.pathname));
} else { } else {
// else, fallback to load the index.html instead // else, fallback to load the index.html instead
filepath = join(webStaticDir, 'index.html'); filepath = join(webStaticDir, 'index.html');
@@ -57,12 +48,12 @@ async function handleHttpRequest(request: Request) {
} }
export function registerProtocol() { export function registerProtocol() {
protocol.handle('http', request => { protocol.handle('file', request => {
return handleHttpRequest(request); return handleFileRequest(request);
}); });
protocol.handle('https', request => { protocol.handle('assets', request => {
return handleHttpRequest(request); return handleFileRequest(request);
}); });
// hack for CORS // hack for CORS
@@ -81,9 +72,49 @@ export function registerProtocol() {
'DELETE', 'DELETE',
'OPTIONS', 'OPTIONS',
]; ];
// replace SameSite=Lax with SameSite=None
const originalCookie =
responseHeaders['set-cookie'] || responseHeaders['Set-Cookie'];
if (originalCookie) {
delete responseHeaders['set-cookie'];
delete responseHeaders['Set-Cookie'];
responseHeaders['Set-Cookie'] = originalCookie.map(cookie => {
let newCookie = cookie.replace(/SameSite=Lax/gi, 'SameSite=None');
// if the cookie is not secure, set it to secure
if (!newCookie.includes('Secure')) {
newCookie = newCookie + '; Secure';
}
return newCookie;
});
}
} }
callback({ responseHeaders }); callback({ responseHeaders });
} }
); );
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
(async () => {
const url = new URL(details.url);
const pathname = url.pathname;
// if sending request to the cloud, attach the session cookie
if (isNetworkResource(pathname)) {
const cookie = await getCookie(CLOUD_BASE_URL);
const cookieString = cookie.map(c => `${c.name}=${c.value}`).join('; ');
details.requestHeaders['cookie'] = cookieString;
}
callback({
cancel: false,
requestHeaders: details.requestHeaders,
});
})().catch(e => {
logger.error('failed to attach cookie', e);
callback({
cancel: false,
requestHeaders: details.requestHeaders,
});
});
});
} }

View File

@@ -6,14 +6,14 @@ import { uiSubjects } from './subject';
*/ */
export const uiEvents = { export const uiEvents = {
onFinishLogin: ( onFinishLogin: (
fn: (result: { success: boolean; email: string }) => void fn: (result: { success: boolean; email?: string }) => void
) => { ) => {
const sub = uiSubjects.onFinishLogin.subscribe(fn); const sub = uiSubjects.onFinishLogin.subscribe(fn);
return () => { return () => {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
onStartLogin: (fn: (opts: { email: string }) => void) => { onStartLogin: (fn: (opts: { email?: string }) => void) => {
const sub = uiSubjects.onStartLogin.subscribe(fn); const sub = uiSubjects.onStartLogin.subscribe(fn);
return () => { return () => {
sub.unsubscribe(); sub.unsubscribe();

View File

@@ -1,6 +1,6 @@
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
export const uiSubjects = { export const uiSubjects = {
onStartLogin: new Subject<{ email: string }>(), onStartLogin: new Subject<{ email?: string }>(),
onFinishLogin: new Subject<{ success: boolean; email: string }>(), onFinishLogin: new Subject<{ success: boolean; email?: string }>(),
}; };

View File

@@ -1,7 +1,7 @@
{ {
"name": "@affine/prototype", "name": "@affine/prototype",
"private": true, "private": true,
"version": "0.9.0-canary.3", "version": "0.9.0-canary.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host --port 3003", "dev": "vite --host --port 3003",
@@ -18,13 +18,13 @@
"@affine/jotai": "workspace:*", "@affine/jotai": "workspace:*",
"@affine/templates": "workspace:*", "@affine/templates": "workspace:*",
"@affine/workspace": "workspace:*", "@affine/workspace": "workspace:*",
"@blocksuite/block-std": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/block-std": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/blocks": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/blocks": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/editor": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/editor": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/global": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/global": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/icons": "^2.1.31", "@blocksuite/icons": "^2.1.31",
"@blocksuite/lit": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/lit": "0.0.0-20230829150056-df43987c-nightly",
"@blocksuite/store": "0.0.0-20230828163942-e5356e86-nightly", "@blocksuite/store": "0.0.0-20230829150056-df43987c-nightly",
"@toeverything/hooks": "workspace:*", "@toeverything/hooks": "workspace:*",
"@toeverything/y-indexeddb": "workspace:*", "@toeverything/y-indexeddb": "workspace:*",
"react": "^18.2.0", "react": "^18.2.0",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@affine/server", "name": "@affine/server",
"private": true, "private": true,
"version": "0.9.0-canary.3", "version": "0.9.0-canary.8",
"description": "Affine Node.js server", "description": "Affine Node.js server",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -11,24 +11,25 @@
"build": "tsc", "build": "tsc",
"start": "node --loader ts-node/esm.mjs --es-module-specifier-resolution node ./src/index.ts", "start": "node --loader ts-node/esm.mjs --es-module-specifier-resolution node ./src/index.ts",
"dev": "nodemon ./src/index.ts", "dev": "nodemon ./src/index.ts",
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all", "test": "ava --concurrency 1 --serial",
"test:watch": "yarn exec ts-node-esm ./scripts/run-test.ts all --watch", "test:coverage": "c8 ava --concurrency 1 --serial",
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.9.2", "@apollo/server": "^4.9.3",
"@auth/prisma-adapter": "^1.0.1", "@auth/prisma-adapter": "^1.0.1",
"@aws-sdk/client-s3": "^3.400.0", "@aws-sdk/client-s3": "^3.400.0",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
"@keyv/redis": "^2.7.0",
"@nestjs/apollo": "^12.0.7", "@nestjs/apollo": "^12.0.7",
"@nestjs/common": "^10.2.2", "@nestjs/common": "^10.2.4",
"@nestjs/core": "^10.2.2", "@nestjs/core": "^10.2.4",
"@nestjs/graphql": "^12.0.8", "@nestjs/graphql": "^12.0.8",
"@nestjs/platform-express": "^10.2.2", "@nestjs/platform-express": "^10.2.4",
"@nestjs/platform-socket.io": "^10.2.2", "@nestjs/platform-socket.io": "^10.2.4",
"@nestjs/websockets": "^10.2.2", "@nestjs/throttler": "^4.2.1",
"@nestjs/websockets": "^10.2.4",
"@node-rs/argon2": "^1.5.2", "@node-rs/argon2": "^1.5.2",
"@node-rs/crc32": "^1.7.2", "@node-rs/crc32": "^1.7.2",
"@node-rs/jsonwebtoken": "^0.2.3", "@node-rs/jsonwebtoken": "^0.2.3",
@@ -49,12 +50,14 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"file-type": "^18.5.0", "file-type": "^18.5.0",
"get-stream": "^7.0.1", "get-stream": "^8.0.1",
"graphql": "^16.8.0", "graphql": "^16.8.0",
"graphql-type-json": "^0.3.2", "graphql-type-json": "^0.3.2",
"graphql-upload": "^16.0.2", "graphql-upload": "^16.0.2",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"keyv": "^4.5.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"nestjs-throttler-storage-redis": "^0.3.3",
"next-auth": "4.22.5", "next-auth": "4.22.5",
"nodemailer": "^6.9.4", "nodemailer": "^6.9.4",
"on-headers": "^1.0.2", "on-headers": "^1.0.2",
@@ -72,11 +75,11 @@
"devDependencies": { "devDependencies": {
"@affine/storage": "workspace:*", "@affine/storage": "workspace:*",
"@napi-rs/image": "^1.6.1", "@napi-rs/image": "^1.6.1",
"@nestjs/testing": "^10.2.2", "@nestjs/testing": "^10.2.4",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/engine.io": "^3.1.7", "@types/engine.io": "^3.1.7",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/lodash-es": "^4.17.8", "@types/lodash-es": "^4.17.9",
"@types/node": "^18.17.12", "@types/node": "^18.17.12",
"@types/nodemailer": "^6.4.9", "@types/nodemailer": "^6.4.9",
"@types/on-headers": "^1.0.0", "@types/on-headers": "^1.0.0",
@@ -84,6 +87,7 @@
"@types/sinon": "^10.0.16", "@types/sinon": "^10.0.16",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@types/ws": "^8.5.5", "@types/ws": "^8.5.5",
"ava": "^5.3.1",
"c8": "^8.0.1", "c8": "^8.0.1",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"sinon": "^15.2.0", "sinon": "^15.2.0",
@@ -91,6 +95,27 @@
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.2.2" "typescript": "^5.2.2"
}, },
"ava": {
"extensions": {
"ts": "module"
},
"nodeArguments": [
"--loader",
"ts-node/esm.mjs",
"--es-module-specifier-resolution",
"node"
],
"files": [
"src/**/*.spec.ts"
],
"require": [
"./src/prelude.ts"
],
"environmentVariables": {
"TS_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "test"
}
},
"nodemonConfig": { "nodemonConfig": {
"exec": "node", "exec": "node",
"script": "./src/index.ts", "script": "./src/index.ts",

View File

@@ -1,72 +0,0 @@
#!/usr/bin/env ts-node-esm
import { resolve } from 'node:path';
import * as p from '@clack/prompts';
import { spawn } from 'child_process';
import { readdir } from 'fs/promises';
import * as process from 'process';
import { fileURLToPath } from 'url';
import pkg from '../package.json' assert { type: 'json' };
const root = fileURLToPath(new URL('..', import.meta.url));
const testDir = resolve(root, 'src', 'tests');
const files = await readdir(testDir);
const watchMode = process.argv.includes('--watch');
const sharedArgs = [
...pkg.nodemonConfig.nodeArgs,
'--test',
watchMode ? '--watch' : '',
];
const env = {
PATH: process.env.PATH,
NODE_ENV: 'test',
DATABASE_URL: process.env.DATABASE_URL,
NODE_NO_WARNINGS: '1',
};
if (process.argv[2] === 'all') {
const cp = spawn('node', [...sharedArgs, resolve(testDir, '*')], {
cwd: root,
env,
stdio: 'inherit',
shell: true,
});
cp.on('exit', code => {
process.exit(code ?? 0);
});
} else {
const result = await p.group({
file: () =>
p.select({
message: 'Select a file to run',
options: files.map(file => ({
label: file,
value: file as any,
})),
}),
});
const target = resolve(testDir, result.file);
const cp = spawn(
'node',
[
...sharedArgs,
'--test-reporter=spec',
'--test-reporter-destination=stdout',
target,
],
{
cwd: root,
env,
stdio: 'inherit',
shell: true,
}
);
cp.on('exit', code => {
process.exit(code ?? 0);
});
}

View File

@@ -5,7 +5,9 @@ import { ConfigModule } from './config';
import { MetricsModule } from './metrics'; import { MetricsModule } from './metrics';
import { BusinessModules } from './modules'; import { BusinessModules } from './modules';
import { PrismaModule } from './prisma'; import { PrismaModule } from './prisma';
import { SessionModule } from './session';
import { StorageModule } from './storage'; import { StorageModule } from './storage';
import { RateLimiterModule } from './throttler';
@Module({ @Module({
imports: [ imports: [
@@ -13,6 +15,8 @@ import { StorageModule } from './storage';
ConfigModule.forRoot(), ConfigModule.forRoot(),
StorageModule.forRoot(), StorageModule.forRoot(),
MetricsModule, MetricsModule,
SessionModule,
RateLimiterModule,
...BusinessModules, ...BusinessModules,
], ],
controllers: [AppController], controllers: [AppController],

View File

@@ -157,6 +157,12 @@ export interface AFFiNEConfig {
* the apollo driver config * the apollo driver config
*/ */
graphql: ApolloDriverConfig; graphql: ApolloDriverConfig;
/**
* app features flag
*/
featureFlags: {
earlyAccessPreview: boolean;
};
/** /**
* object storage Config * object storage Config
* *
@@ -180,7 +186,31 @@ export interface AFFiNEConfig {
fs: { fs: {
path: string; path: string;
}; };
/**
* Free user storage quota
* @default 10 * 1024 * 1024 (10GB)
*/
quota: number;
}; };
/**
* Rate limiter config
*/
rateLimiter: {
/**
* How long each request will be throttled (seconds)
* @default 60
* @env THROTTLE_TTL
*/
ttl: number;
/**
* How many requests can be made in the given time frame
* @default 60
* @env THROTTLE_LIMIT
*/
limit: number;
};
/** /**
* Redis Config * Redis Config
* *
@@ -201,6 +231,15 @@ export interface AFFiNEConfig {
port: number; port: number;
username: string; username: string;
password: string; password: string;
/**
* redis database index
*
* Rate Limiter scope: database + 1
*
* Session scope: database + 2
*
* @default 0
*/
database: number; database: number;
}; };

View File

@@ -55,8 +55,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
AFFINE_SERVER_HOST: 'host', AFFINE_SERVER_HOST: 'host',
AFFINE_SERVER_SUB_PATH: 'path', AFFINE_SERVER_SUB_PATH: 'path',
AFFINE_ENV: 'affineEnv', AFFINE_ENV: 'affineEnv',
AFFINE_FREE_USER_QUOTA: 'objectStorage.quota',
DATABASE_URL: 'db.url', DATABASE_URL: 'db.url',
AUTH_PRIVATE_KEY: 'auth.privateKey',
ENABLE_R2_OBJECT_STORAGE: ['objectStorage.r2.enabled', 'boolean'], ENABLE_R2_OBJECT_STORAGE: ['objectStorage.r2.enabled', 'boolean'],
R2_OBJECT_STORAGE_ACCOUNT_ID: 'objectStorage.r2.accountId', R2_OBJECT_STORAGE_ACCOUNT_ID: 'objectStorage.r2.accountId',
R2_OBJECT_STORAGE_ACCESS_KEY_ID: 'objectStorage.r2.accessKeyId', R2_OBJECT_STORAGE_ACCESS_KEY_ID: 'objectStorage.r2.accessKeyId',
@@ -73,6 +73,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
OAUTH_EMAIL_SERVER: 'auth.email.server', OAUTH_EMAIL_SERVER: 'auth.email.server',
OAUTH_EMAIL_PORT: ['auth.email.port', 'int'], OAUTH_EMAIL_PORT: ['auth.email.port', 'int'],
OAUTH_EMAIL_PASSWORD: 'auth.email.password', OAUTH_EMAIL_PASSWORD: 'auth.email.password',
THROTTLE_TTL: ['rateLimiter.ttl', 'int'],
THROTTLE_LIMIT: ['rateLimiter.limit', 'int'],
REDIS_SERVER_ENABLED: ['redis.enabled', 'boolean'], REDIS_SERVER_ENABLED: ['redis.enabled', 'boolean'],
REDIS_SERVER_HOST: 'redis.host', REDIS_SERVER_HOST: 'redis.host',
REDIS_SERVER_PORT: ['redis.port', 'int'], REDIS_SERVER_PORT: ['redis.port', 'int'],
@@ -106,6 +108,12 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
get deploy() { get deploy() {
return !this.node.dev && !this.node.test; return !this.node.dev && !this.node.test;
}, },
get featureFlags() {
return {
earlyAccessPreview:
this.node.prod && (this.affine.beta || this.affine.canary),
};
},
https: false, https: false,
host: 'localhost', host: 'localhost',
port: 3010, port: 3010,
@@ -163,6 +171,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
fs: { fs: {
path: join(homedir(), '.affine-storage'), path: join(homedir(), '.affine-storage'),
}, },
quota: 10 * 1024 * 1024,
},
rateLimiter: {
ttl: 60,
limit: 60,
}, },
redis: { redis: {
enabled: false, enabled: false,

View File

@@ -7,7 +7,6 @@ import { Plugin } from '@nestjs/apollo';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { OPERATION_NAME, REQUEST_ID } from '../constants';
import { Metrics } from '../metrics/metrics'; import { Metrics } from '../metrics/metrics';
import { ReqContext } from '../types'; import { ReqContext } from '../types';
@@ -22,19 +21,10 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
): Promise<GraphQLRequestListener<GraphQLRequestContext<ReqContext>>> { ): Promise<GraphQLRequestListener<GraphQLRequestContext<ReqContext>>> {
const res = reqContext.contextValue.req.res as Response; const res = reqContext.contextValue.req.res as Response;
const operation = reqContext.request.operationName; const operation = reqContext.request.operationName;
const headers = reqContext.request.http?.headers;
const requestId = headers
? headers.get(`${REQUEST_ID}`)
: 'Unknown Request ID';
const operationName = headers
? headers.get(`${OPERATION_NAME}`)
: 'Unknown Operation Name';
this.metrics.gqlRequest(1, { operation }); this.metrics.gqlRequest(1, { operation });
const timer = this.metrics.gqlTimer({ operation }); const timer = this.metrics.gqlTimer({ operation });
const requestInfo = `${REQUEST_ID}: ${requestId}, ${OPERATION_NAME}: ${operationName}`;
return Promise.resolve({ return Promise.resolve({
willSendResponse: () => { willSendResponse: () => {
const costInMilliseconds = timer() * 1000; const costInMilliseconds = timer() * 1000;
@@ -42,7 +32,6 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
'Server-Timing', 'Server-Timing',
`gql;dur=${costInMilliseconds};desc="GraphQL"` `gql;dur=${costInMilliseconds};desc="GraphQL"`
); );
this.logger.log(requestInfo);
return Promise.resolve(); return Promise.resolve();
}, },
didEncounterErrors: () => { didEncounterErrors: () => {
@@ -52,7 +41,6 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
'Server-Timing', 'Server-Timing',
`gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"` `gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"`
); );
this.logger.error(`${requestInfo}, query: ${reqContext.request.query}`);
return Promise.resolve(); return Promise.resolve();
}, },
}); });

View File

@@ -24,10 +24,11 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from './app'; import { AppModule } from './app';
import { Config } from './config'; import { Config } from './config';
import { ExceptionLogger } from './middleware/exception-logger';
import { serverTimingAndCache } from './middleware/timing'; import { serverTimingAndCache } from './middleware/timing';
import { RedisIoAdapter } from './modules/sync/redis-adapter'; import { RedisIoAdapter } from './modules/sync/redis-adapter';
const { NODE_ENV } = process.env; const { NODE_ENV, AFFINE_ENV } = process.env;
if (NODE_ENV === 'production') { if (NODE_ENV === 'production') {
const traceExporter = new TraceExporter(); const traceExporter = new TraceExporter();
@@ -60,7 +61,10 @@ if (NODE_ENV === 'production') {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: true, cors: true,
bodyParser: true, bodyParser: true,
logger: NODE_ENV === 'production' ? ['log'] : ['verbose'], logger:
NODE_ENV !== 'production' || AFFINE_ENV !== 'production'
? ['verbose']
: ['log'],
}); });
app.use(serverTimingAndCache); app.use(serverTimingAndCache);
@@ -72,11 +76,12 @@ app.use(
}) })
); );
app.useGlobalFilters(new ExceptionLogger());
app.use(cookieParser()); app.use(cookieParser());
const config = app.get(Config); const config = app.get(Config);
const host = config.host ?? 'localhost'; const host = config.node.prod ? '0.0.0.0' : 'localhost';
const port = config.port ?? 3010; const port = config.port ?? 3010;
if (!config.objectStorage.r2.enabled) { if (!config.objectStorage.r2.enabled) {

View File

@@ -0,0 +1,38 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { REQUEST_ID } from '../constants';
@Catch(HttpException)
export class ExceptionLogger implements ExceptionFilter {
private logger = new Logger('ExceptionLogger');
catch(exception: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const requestId = request?.header(REQUEST_ID);
this.logger.error(
new Error(
`${requestId ? `requestId-${requestId}:` : ''}${exception.message}`,
{ cause: exception }
),
exception.stack
);
const response = ctx.getResponse<Response>();
if (exception instanceof HttpException) {
response.json(exception.getResponse());
} else {
response.status(500).json({
message: exception.message,
statusCode: 500,
});
}
}
}

View File

@@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { SessionService } from '../../session';
import { MAILER, MailService } from './mailer'; import { MAILER, MailService } from './mailer';
import { NextAuthController } from './next-auth.controller'; import { NextAuthController } from './next-auth.controller';
import { NextAuthOptionsProvider } from './next-auth-options'; import { NextAuthOptionsProvider } from './next-auth-options';
@@ -10,6 +11,7 @@ import { AuthService } from './service';
@Module({ @Module({
providers: [ providers: [
AuthService, AuthService,
SessionService,
AuthResolver, AuthResolver,
NextAuthOptionsProvider, NextAuthOptionsProvider,
MAILER, MAILER,

View File

@@ -42,34 +42,45 @@ export class MailService {
}; };
} }
) { ) {
console.log('invitationInfo', invitationInfo); // TODO: use callback url when need support desktop app
const buttonUrl = `${this.config.origin}/invite/${inviteId}`;
const buttonUrl = `${this.config.baseUrl}/invite/${inviteId}`;
const workspaceAvatar = invitationInfo.workspace.avatar; const workspaceAvatar = invitationInfo.workspace.avatar;
const content = ` <img const content = `${
invitationInfo.user.avatar
? `<img
src="${invitationInfo.user.avatar}" src="${invitationInfo.user.avatar}"
alt="" alt=""
width="24px" width="24px"
height="24px" height="24px"
style="border-radius: 12px;object-fit: cover;vertical-align: middle" style="width:24px; height:24px; border-radius: 12px;object-fit: cover;vertical-align: middle"
/> />`
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.user.name}</span> : ''
}
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${
invitationInfo.user.name
}</span>
<span>invited you to join</span> <span>invited you to join</span>
<img <img
src="cid:workspaceAvatar" src="cid:workspaceAvatar"
alt="" alt=""
width="24px" width="24px"
height="24px" height="24px"
style="margin-left:10px;border-radius: 12px;object-fit: cover;vertical-align: middle" style="width:24px; height:24px; margin-left:10px;border-radius: 12px;object-fit: cover;vertical-align: middle"
/> />
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.workspace.name}</span>`; <span style="font-weight:500;margin-left:4px;margin-right: 10px;">${
invitationInfo.workspace.name
}</span>`;
const subContent =
'Currently, AFFiNE Cloud is in the early access stage. Only Early Access Sponsors can register and log in to AFFiNE Cloud.<a href="https://community.affine.pro/c/insider-general/" style="color: #1e67af" >Please click here for more information.</a>';
const html = emailTemplate({ const html = emailTemplate({
title: 'You are invited!', title: 'You are invited!',
content, content,
buttonContent: 'Accept & Join', buttonContent: 'Accept & Join',
buttonUrl, buttonUrl,
subContent,
}); });
return this.sendMail({ return this.sendMail({
@@ -87,43 +98,65 @@ export class MailService {
], ],
}); });
} }
async sendSignInEmail(url: string, options: Options) {
const html = emailTemplate({
title: 'Sign in to AFFiNE',
content:
'Click the button below to securely sign in. The magic link will expire in 30 minutes.',
buttonContent: 'Sign in to AFFiNE',
buttonUrl: url,
});
return this.sendMail({
html,
subject: 'Sign in to AFFiNE',
...options,
});
}
async sendChangePasswordEmail(to: string, url: string) { async sendChangePasswordEmail(to: string, url: string) {
const html = ` const html = emailTemplate({
<h1>Change password</h1> title: 'Modify your AFFiNE password',
<p>Click button to open change password page</p> content:
<a href="${url}">${url}</a> 'Click the button below to reset your password. The magic link will expire in 30 minutes.',
`; buttonContent: 'Set new password',
buttonUrl: url,
});
return this.sendMail({ return this.sendMail({
from: this.config.auth.email.sender, from: this.config.auth.email.sender,
to, to,
subject: `Change password`, subject: `Modify your AFFiNE password`,
html, html,
}); });
} }
async sendSetPasswordEmail(to: string, url: string) { async sendSetPasswordEmail(to: string, url: string) {
const html = ` const html = emailTemplate({
<h1>Set password</h1> title: 'Set your AFFiNE password',
<p>Click button to open set password page</p> content:
<a href="${url}">${url}</a> 'Click the button below to set your password. The magic link will expire in 30 minutes.',
`; buttonContent: 'Set your password',
buttonUrl: url,
});
return this.sendMail({ return this.sendMail({
from: this.config.auth.email.sender, from: this.config.auth.email.sender,
to, to,
subject: `Change password`, subject: `Set your AFFiNE password`,
html, html,
}); });
} }
async sendChangeEmail(to: string, url: string) { async sendChangeEmail(to: string, url: string) {
const html = ` const html = emailTemplate({
<h1>Change Email</h1> title: 'Verify your current email for AFFiNE',
<p>Click button to open change email page</p> content:
<a href="${url}">${url}</a> 'You recently requested to change 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 and set up a new email address',
buttonUrl: url,
});
return this.sendMail({ return this.sendMail({
from: this.config.auth.email.sender, from: this.config.auth.email.sender,
to, to,
subject: `Change password`, subject: `Verify your current email for AFFiNE`,
html, html,
}); });
} }

View File

@@ -3,11 +3,13 @@ export const emailTemplate = ({
content, content,
buttonContent, buttonContent,
buttonUrl, buttonUrl,
subContent,
}: { }: {
title: string; title: string;
content: string; content: string;
buttonContent: string; buttonContent: string;
buttonUrl: string; buttonUrl: string;
subContent?: string;
}) => { }) => {
return `<body style="background: #f6f7fb; overflow: hidden"> return `<body style="background: #f6f7fb; overflow: hidden">
<table <table
@@ -58,7 +60,9 @@ export const emailTemplate = ({
>${content}</td> >${content}</td>
</tr> </tr>
<tr> <tr>
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px"> <td style="margin-left: 24px; padding-top: 0; padding-bottom: ${
subContent ? '0' : '64px'
}">
<table border="0" cellspacing="0" cellpadding="0"> <table border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td style="border-radius: 8px" bgcolor="#1E96EB"> <td style="border-radius: 8px" bgcolor="#1E96EB">
@@ -85,6 +89,24 @@ export const emailTemplate = ({
</table> </table>
</td> </td>
</tr> </tr>
${
subContent
? `<tr>
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #444;
padding-top: 24px;
"
>
${subContent}
</td>
</tr>`
: ''
}
</table> </table>
<table <table
width="100%" width="100%"

View File

@@ -4,6 +4,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter';
import { BadRequestException, FactoryProvider, Logger } from '@nestjs/common'; import { BadRequestException, FactoryProvider, Logger } from '@nestjs/common';
import { verify } from '@node-rs/argon2'; import { verify } from '@node-rs/argon2';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken'; import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { nanoid } from 'nanoid';
import { NextAuthOptions } from 'next-auth'; import { NextAuthOptions } from 'next-auth';
import Credentials from 'next-auth/providers/credentials'; import Credentials from 'next-auth/providers/credentials';
import Email, { import Email, {
@@ -14,32 +15,22 @@ import Google from 'next-auth/providers/google';
import { Config } from '../../config'; import { Config } from '../../config';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { SessionService } from '../../session';
import { NewFeaturesKind } from '../users/types'; import { NewFeaturesKind } from '../users/types';
import { isStaff } from '../users/utils';
import { MailService } from './mailer'; import { MailService } from './mailer';
import { getUtcTimestamp, UserClaim } from './service'; import { getUtcTimestamp, UserClaim } from './service';
export const NextAuthOptionsProvide = Symbol('NextAuthOptions'); export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
function getSchemaFromCallbackUrl(origin: string, callbackUrl: string) {
const { searchParams } = new URL(callbackUrl, origin);
return searchParams.has('schema') ? searchParams.get('schema') : null;
}
function wrapUrlWithOpenApp(
origin: string,
url: string,
schema: string | null
) {
if (schema) {
const urlWithSchema = `${schema}://sign-in?${url}`;
return `${origin}/open-app?url=${encodeURIComponent(urlWithSchema)}`;
}
return url;
}
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = { export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
provide: NextAuthOptionsProvide, provide: NextAuthOptionsProvide,
useFactory(config: Config, prisma: PrismaService, mailer: MailService) { useFactory(
config: Config,
prisma: PrismaService,
mailer: MailService,
session: SessionService
) {
const logger = new Logger('NextAuth'); const logger = new Logger('NextAuth');
const prismaAdapter = PrismaAdapter(prisma); const prismaAdapter = PrismaAdapter(prisma);
// createUser exists in the adapter // createUser exists in the adapter
@@ -88,25 +79,35 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
from: config.auth.email.sender, from: config.auth.email.sender,
async sendVerificationRequest(params: SendVerificationRequestParams) { async sendVerificationRequest(params: SendVerificationRequestParams) {
const { identifier, url, provider } = params; const { identifier, url, provider } = params;
const { host, searchParams, origin } = new URL(url); const urlWithToken = new URL(url);
const callbackUrl = searchParams.get('callbackUrl') || ''; const callbackUrl =
urlWithToken.searchParams.get('callbackUrl') || '';
if (!callbackUrl) { if (!callbackUrl) {
throw new Error('callbackUrl is not set'); throw new Error('callbackUrl is not set');
} } else {
// hack: check if link is opened via desktop const newCallbackUrl = new URL(callbackUrl, config.origin);
const schema = getSchemaFromCallbackUrl(origin, callbackUrl);
const wrappedUrl = wrapUrlWithOpenApp(origin, url, schema);
const result = await mailer.sendMail({ const token = nanoid();
to: identifier, await session.set(token, identifier);
from: provider.from, newCallbackUrl.searchParams.set('token', token);
subject: `Sign in to ${host}`,
text: text({ url: wrappedUrl, host }), urlWithToken.searchParams.set(
html: html({ url: wrappedUrl, host }), 'callbackUrl',
}); newCallbackUrl.toString()
);
}
const result = await mailer.sendSignInEmail(
urlWithToken.toString(),
{
to: identifier,
from: provider.from,
}
);
logger.log( logger.log(
`send verification email success: ${result.accepted.join(', ')}` `send verification email success: ${result.accepted.join(', ')}`
); );
const failed = result.rejected const failed = result.rejected
.concat(result.pending) .concat(result.pending)
.filter(Boolean); .filter(Boolean);
@@ -120,7 +121,7 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
adapter: prismaAdapter, adapter: prismaAdapter,
debug: !config.node.prod, debug: !config.node.prod,
session: { session: {
strategy: config.node.prod ? 'database' : 'jwt', strategy: 'jwt',
}, },
// @ts-expect-error Third part library type mismatch // @ts-expect-error Third part library type mismatch
logger: console, logger: console,
@@ -273,11 +274,14 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
return session; return session;
}, },
signIn: async ({ profile, user }) => { signIn: async ({ profile, user }) => {
if (!config.affine.beta || !config.node.prod) { if (!config.featureFlags.earlyAccessPreview) {
return true; return true;
} }
const email = profile?.email ?? user.email; const email = profile?.email ?? user.email;
if (email) { if (email) {
if (isStaff(email)) {
return true;
}
return prisma.newFeaturesWaitingList return prisma.newFeaturesWaitingList
.findUnique({ .findUnique({
where: { where: {
@@ -296,213 +300,5 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
}; };
return nextAuthOptions; return nextAuthOptions;
}, },
inject: [Config, PrismaService, MailService], inject: [Config, PrismaService, MailService, SessionService],
}; };
/**
* Email HTML body
* Insert invisible space into domains from being turned into a hyperlink by email
* clients like Outlook and Apple mail, as this is confusing because it seems
* like they are supposed to click on it to sign in.
*
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
*/
function html(params: { url: string; host: string }) {
const { url } = params;
return `
<body style="background: #f6f7fb;overflow:hidden">
<table
width="100%"
border="0"
cellpadding="24px"
style="
background: #fff;
max-width: 450px;
margin: 32px auto 0 auto;
border-radius: 16px 16px 0 0;
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
"
>
<tr>
<td>
<a href="https://affine.pro" target="_blank">
<img
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
alt="AFFiNE log"
height="32px"
/>
</a>
</td>
</tr>
<tr>
<td
style="
font-size: 20px;
font-weight: 600;
line-height: 28px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #444;
padding-top: 0;
"
>
Verify your new email for AFFiNE
</td>
</tr>
<tr>
<td
style="
font-size: 15px;
font-weight: 400;
line-height: 24px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #444;
padding-top: 0;
"
>
You recently requested to change the email address associated with your
AFFiNe account. To complete this process, please click on the
verification link below.
</td>
</tr>
<tr>
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius: 8px" bgcolor="#1E96EB">
<a
href="${url}"
target="_blank"
style="
font-size: 15px;
font-family: inter, Arial, Helvetica, sans-serif;
font-weight: 600;
line-height: 24px;
color: #fff;
text-decoration: none;
border-radius: 8px;
padding: 8px 18px;
border: 1px solid #1e96eb;
display: inline-block;
font-weight: bold;
"
>Verify your new email address</a
>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table
width="100%"
border="0"
style="
background: #fafafa;
max-width: 450px;
margin: 0 auto 32px auto;
border-radius: 0 0 16px 16px;
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
padding: 20px;
"
>
<tr align="center">
<td>
<table cellpadding="0">
<tr>
<td style="padding: 0 10px">
<a href="https://github.com/toeverything/AFFiNE" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
alt="AFFiNE github link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://twitter.com/AffineOfficial" target="_blank">
<img
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
alt="AFFiNE twitter link"
height="16px"
/>
</a>
</td>
<td style="padding: 0 10px">
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
alt="AFFiNE discord link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://www.youtube.com/@affinepro" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
alt="AFFiNE youtube link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://t.me/affineworkos" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
alt="AFFiNE telegram link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://www.reddit.com/r/Affine/" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
alt="AFFiNE reddit link"
height="16px"
/></a>
</td>
</tr>
</table>
</td>
</tr>
<tr align="center">
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #8e8d91;
padding-top: 8px;
"
>
One hyper-fused platform for wildly creative minds
</td>
</tr>
<tr align="center">
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: inter, Arial, Helvetica, sans-serif;
color: #8e8d91;
padding-top: 8px;
"
>
Copyright<img
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
alt="copyright"
height="14px"
style="vertical-align: middle; margin: 0 4px"
/>2023 Toeverything
</td>
</tr>
</table>
</body>
`;
}
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) {
return `Sign in to ${host}\n${url}\n\n`;
}

View File

@@ -9,6 +9,7 @@ import {
Query, Query,
Req, Req,
Res, Res,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { hash, verify } from '@node-rs/argon2'; import { hash, verify } from '@node-rs/argon2';
import type { User } from '@prisma/client'; import type { User } from '@prisma/client';
@@ -19,6 +20,7 @@ import { AuthHandler } from 'next-auth/core';
import { Config } from '../../config'; import { Config } from '../../config';
import { PrismaService } from '../../prisma/service'; import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { NextAuthOptionsProvide } from './next-auth-options'; import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service'; import { AuthService } from './service';
@@ -41,6 +43,8 @@ export class NextAuthController {
this.callbackSession = nextAuthOptions.callbacks!.session; this.callbackSession = nextAuthOptions.callbacks!.session;
} }
@UseGuards(CloudThrottlerGuard)
@Throttle(60, 60)
@All('*') @All('*')
async auth( async auth(
@Req() req: Request, @Req() req: Request,
@@ -123,11 +127,15 @@ export class NextAuthController {
} }
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) { if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
res.status(403); if (!req.headers?.referer) {
res.json({ res.redirect('https://community.affine.pro/c/insider-general/');
url: 'https://community.affine.pro/c/insider-general/', } else {
error: `You don't have early access permission`, res.status(403);
}); res.json({
url: 'https://community.affine.pro/c/insider-general/',
error: `You don't have early access permission`,
});
}
return; return;
} }
@@ -136,7 +144,6 @@ export class NextAuthController {
} }
if (redirect) { if (redirect) {
this.logger.debug(providerId, action, req.headers);
if (providerId === 'credentials') { if (providerId === 'credentials') {
res.send(JSON.stringify({ ok: true, url: redirect })); res.send(JSON.stringify({ ok: true, url: redirect }));
} else if ( } else if (

View File

@@ -1,4 +1,8 @@
import { ForbiddenException } from '@nestjs/common'; import {
BadRequestException,
ForbiddenException,
UseGuards,
} from '@nestjs/common';
import { import {
Args, Args,
Context, Context,
@@ -10,10 +14,13 @@ import {
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import type { Request } from 'express'; import type { Request } from 'express';
import { nanoid } from 'nanoid';
import { Config } from '../../config'; import { Config } from '../../config';
import { SessionService } from '../../session';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { UserType } from '../users/resolver'; import { UserType } from '../users/resolver';
import { CurrentUser } from './guard'; import { Auth, CurrentUser } from './guard';
import { AuthService } from './service'; import { AuthService } from './service';
@ObjectType() @ObjectType()
@@ -25,17 +32,26 @@ export class TokenType {
refresh!: string; refresh!: string;
} }
/**
* Auth resolver
* Token rate limit: 20 req/m
* Sign up/in rate limit: 10 req/m
* Other rate limit: 5 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Resolver(() => UserType) @Resolver(() => UserType)
export class AuthResolver { export class AuthResolver {
constructor( constructor(
private readonly config: Config, private readonly config: Config,
private auth: AuthService private readonly auth: AuthService,
private readonly session: SessionService
) {} ) {}
@Throttle(20, 60)
@ResolveField(() => TokenType) @ResolveField(() => TokenType)
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) { token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
if (user !== currentUser) { if (user.id !== currentUser.id) {
throw new ForbiddenException(); throw new BadRequestException('Invalid user');
} }
return { return {
@@ -44,6 +60,7 @@ export class AuthResolver {
}; };
} }
@Throttle(10, 60)
@Mutation(() => UserType) @Mutation(() => UserType)
async signUp( async signUp(
@Context() ctx: { req: Request }, @Context() ctx: { req: Request },
@@ -56,6 +73,7 @@ export class AuthResolver {
return user; return user;
} }
@Throttle(10, 60)
@Mutation(() => UserType) @Mutation(() => UserType)
async signIn( async signIn(
@Context() ctx: { req: Request }, @Context() ctx: { req: Request },
@@ -67,55 +85,95 @@ export class AuthResolver {
return user; return user;
} }
@Throttle(5, 60)
@Mutation(() => UserType) @Mutation(() => UserType)
@Auth()
async changePassword( async changePassword(
@Context() ctx: { req: Request }, @CurrentUser() user: UserType,
@Args('id') id: string, @Args('token') token: string,
@Args('newPassword') newPassword: string @Args('newPassword') newPassword: string
) { ) {
const user = await this.auth.changePassword(id, newPassword); const id = await this.session.get(token);
ctx.req.user = user; if (!id || id !== user.id) {
throw new ForbiddenException('Invalid token');
}
await this.auth.changePassword(id, newPassword);
await this.session.delete(token);
return user; return user;
} }
@Throttle(5, 60)
@Mutation(() => UserType) @Mutation(() => UserType)
@Auth()
async changeEmail( async changeEmail(
@Context() ctx: { req: Request }, @CurrentUser() user: UserType,
@Args('id') id: string, @Args('token') token: string,
@Args('email') email: string @Args('email') email: string
) { ) {
const user = await this.auth.changeEmail(id, email); const id = await this.session.get(token);
ctx.req.user = user; if (!id || id !== user.id) {
throw new ForbiddenException('Invalid token');
}
await this.auth.changeEmail(id, email);
await this.session.delete(token);
return user; return user;
} }
@Throttle(5, 60)
@Mutation(() => Boolean) @Mutation(() => Boolean)
@Auth()
async sendChangePasswordEmail( async sendChangePasswordEmail(
@CurrentUser() user: UserType,
@Args('email') email: string, @Args('email') email: string,
@Args('callbackUrl') callbackUrl: string @Args('callbackUrl') callbackUrl: string
) { ) {
const url = `${this.config.baseUrl}${callbackUrl}`; const token = nanoid();
const res = await this.auth.sendChangePasswordEmail(email, url); await this.session.set(token, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendChangePasswordEmail(email, url.toString());
return !res.rejected.length; return !res.rejected.length;
} }
@Throttle(5, 60)
@Mutation(() => Boolean) @Mutation(() => Boolean)
@Auth()
async sendSetPasswordEmail( async sendSetPasswordEmail(
@CurrentUser() user: UserType,
@Args('email') email: string, @Args('email') email: string,
@Args('callbackUrl') callbackUrl: string @Args('callbackUrl') callbackUrl: string
) { ) {
const url = `${this.config.baseUrl}${callbackUrl}`; const token = nanoid();
const res = await this.auth.sendSetPasswordEmail(email, url); await this.session.set(token, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendSetPasswordEmail(email, url.toString());
return !res.rejected.length; return !res.rejected.length;
} }
@Throttle(5, 60)
@Mutation(() => Boolean) @Mutation(() => Boolean)
@Auth()
async sendChangeEmail( async sendChangeEmail(
@CurrentUser() user: UserType,
@Args('email') email: string, @Args('email') email: string,
@Args('callbackUrl') callbackUrl: string @Args('callbackUrl') callbackUrl: string
) { ) {
const url = `${this.config.baseUrl}${callbackUrl}`; const token = nanoid();
const res = await this.auth.sendChangeEmail(email, url); await this.session.set(token, user.id);
const url = new URL(callbackUrl, this.config.baseUrl);
url.searchParams.set('token', token);
const res = await this.auth.sendChangeEmail(email, url.toString());
return !res.rejected.length; return !res.rejected.length;
} }
} }

View File

@@ -67,8 +67,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
protected recoverDoc(...updates: Buffer[]): Doc { protected recoverDoc(...updates: Buffer[]): Doc {
const doc = new Doc(); const doc = new Doc();
updates.forEach(update => { updates.forEach((update, i) => {
applyUpdate(doc, update); try {
if (update.length) {
applyUpdate(doc, update);
}
} catch (e) {
this.logger.error(
`Failed to apply updates, index: ${i}`,
updates.map(u => u.toString('hex'))
);
}
}); });
return doc; return doc;

View File

@@ -105,6 +105,8 @@ export class RedisDocManager extends DocManager {
.catch(() => null); // safe; .catch(() => null); // safe;
if (!lockResult) { if (!lockResult) {
// we failed to acquire the lock, put the pending doc back to queue.
await this.redis.sadd(pending, pendingDoc).catch(() => null); // safe
return; return;
} }
@@ -141,7 +143,10 @@ export class RedisDocManager extends DocManager {
this.logger.error('Failed to remove merged updates from Redis', e); this.logger.error('Failed to remove merged updates from Redis', e);
}); });
} catch (e) { } catch (e) {
this.logger.error('Failed to merge updates with snapshot', e); this.logger.error(
`Failed to merge updates with snapshot for ${pendingDoc}`,
e
);
await this.redis.sadd(pending, `${workspaceId}:${id}`).catch(() => null); // safe await this.redis.sadd(pending, `${workspaceId}:${id}`).catch(() => null); // safe
} finally { } finally {
await this.redis.del(lockKey); await this.redis.del(lockKey);

View File

@@ -2,6 +2,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { crc32 } from '@node-rs/crc32'; import { crc32 } from '@node-rs/crc32';
import { fileTypeFromBuffer } from 'file-type'; import { fileTypeFromBuffer } from 'file-type';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - no types
import { getStreamAsBuffer } from 'get-stream'; import { getStreamAsBuffer } from 'get-stream';
import { Config } from '../../config'; import { Config } from '../../config';

View File

@@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
HttpException, HttpException,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
Args, Args,
@@ -19,10 +20,12 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { Config } from '../../config'; import { Config } from '../../config';
import { PrismaService } from '../../prisma/service'; import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types'; import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public } from '../auth/guard'; import { Auth, CurrentUser, Public } from '../auth/guard';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { NewFeaturesKind } from './types'; import { NewFeaturesKind } from './types';
import { isStaff } from './utils';
registerEnumType(NewFeaturesKind, { registerEnumType(NewFeaturesKind, {
name: 'NewFeaturesKind', name: 'NewFeaturesKind',
@@ -69,6 +72,11 @@ export class AddToNewFeaturesWaitingList {
type!: NewFeaturesKind; type!: NewFeaturesKind;
} }
/**
* User resolver
* All op rate limit: 10 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth() @Auth()
@Resolver(() => UserType) @Resolver(() => UserType)
export class UserResolver { export class UserResolver {
@@ -78,22 +86,30 @@ export class UserResolver {
private readonly config: Config private readonly config: Config
) {} ) {}
@Throttle(10, 60)
@Query(() => UserType, { @Query(() => UserType, {
name: 'currentUser', name: 'currentUser',
description: 'Get current user', description: 'Get current user',
}) })
async currentUser(@CurrentUser() user: User) { async currentUser(@CurrentUser() user: UserType) {
const storedUser = await this.prisma.user.findUnique({
where: { id: user.id },
});
if (!storedUser) {
throw new BadRequestException(`User ${user.id} not found in db`);
}
return { return {
id: user.id, id: storedUser.id,
name: user.name, name: storedUser.name,
email: user.email, email: storedUser.email,
emailVerified: user.emailVerified, emailVerified: storedUser.emailVerified,
avatarUrl: user.avatarUrl, avatarUrl: storedUser.avatarUrl,
createdAt: user.createdAt, createdAt: storedUser.createdAt,
hasPassword: !!user.password, hasPassword: !!storedUser.password,
}; };
} }
@Throttle(10, 60)
@Query(() => UserType, { @Query(() => UserType, {
name: 'user', name: 'user',
description: 'Get user by email', description: 'Get user by email',
@@ -101,7 +117,7 @@ export class UserResolver {
}) })
@Public() @Public()
async user(@Args('email') email: string) { async user(@Args('email') email: string) {
if (this.config.node.prod && this.config.affine.beta) { if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
const hasEarlyAccess = await this.prisma.newFeaturesWaitingList const hasEarlyAccess = await this.prisma.newFeaturesWaitingList
.findUnique({ .findUnique({
where: { email, type: NewFeaturesKind.EarlyAccess }, where: { email, type: NewFeaturesKind.EarlyAccess },
@@ -129,6 +145,7 @@ export class UserResolver {
return user; return user;
} }
@Throttle(10, 60)
@Mutation(() => UserType, { @Mutation(() => UserType, {
name: 'uploadAvatar', name: 'uploadAvatar',
description: 'Upload user avatar', description: 'Upload user avatar',
@@ -149,6 +166,7 @@ export class UserResolver {
}); });
} }
@Throttle(10, 60)
@Mutation(() => DeleteAccount) @Mutation(() => DeleteAccount)
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> { async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
await this.prisma.user.delete({ await this.prisma.user.delete({
@@ -166,6 +184,7 @@ export class UserResolver {
}; };
} }
@Throttle(10, 60)
@Mutation(() => AddToNewFeaturesWaitingList) @Mutation(() => AddToNewFeaturesWaitingList)
async addToNewFeaturesWaitingList( async addToNewFeaturesWaitingList(
@CurrentUser() user: UserType, @CurrentUser() user: UserType,

View File

@@ -0,0 +1,3 @@
export function isStaff(email: string) {
return email.endsWith('@toeverything.info');
}

View File

@@ -48,7 +48,11 @@ export class PermissionService {
return true; return true;
} else { } else {
// check if this is a public subpage // check if this is a public subpage
return subpages.map(page => `space:${page}`).includes(id);
// why use `endsWith`?
// because there might have `${wsId}:space:${subpageId}`,
// but subpages only have `${subpageId}`
return subpages.some(subpage => id.endsWith(subpage));
} }
} }
} }

View File

@@ -1,5 +1,10 @@
import type { Storage } from '@affine/storage'; import type { Storage } from '@affine/storage';
import { ForbiddenException, Inject, NotFoundException } from '@nestjs/common'; import {
ForbiddenException,
Inject,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { import {
Args, Args,
Field, Field,
@@ -22,8 +27,10 @@ import type { User, Workspace } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs'; import { applyUpdate, Doc } from 'yjs';
import { Config } from '../../config';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage'; import { StorageProvide } from '../../storage';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types'; import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public } from '../auth'; import { Auth, CurrentUser, Public } from '../auth';
import { MailService } from '../auth/mailer'; import { MailService } from '../auth/mailer';
@@ -89,6 +96,12 @@ export class InvitationWorkspaceType {
avatar!: string; avatar!: string;
} }
@ObjectType()
export class WorkspaceBlobSizes {
@Field(() => Int)
size!: number;
}
@ObjectType() @ObjectType()
export class InvitationType { export class InvitationType {
@Field({ description: 'Workspace information' }) @Field({ description: 'Workspace information' })
@@ -107,11 +120,18 @@ export class UpdateWorkspaceInput extends PickType(
id!: string; id!: string;
} }
/**
* Workspace resolver
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth() @Auth()
@Resolver(() => WorkspaceType) @Resolver(() => WorkspaceType)
export class WorkspaceResolver { export class WorkspaceResolver {
constructor( constructor(
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly config: Config,
private readonly mailer: MailService, private readonly mailer: MailService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService, private readonly permissionProvider: PermissionService,
@@ -252,10 +272,11 @@ export class WorkspaceResolver {
}); });
} }
@Throttle(10, 30)
@Public()
@Query(() => WorkspaceType, { @Query(() => WorkspaceType, {
description: 'Get public workspace by id', description: 'Get public workspace by id',
}) })
@Public()
async publicWorkspace(@Args('id') id: string) { async publicWorkspace(@Args('id') id: string) {
const workspace = await this.prisma.workspace.findUnique({ const workspace = await this.prisma.workspace.findUnique({
where: { id }, where: { id },
@@ -320,13 +341,15 @@ export class WorkspaceResolver {
}, },
}); });
await this.prisma.snapshot.create({ if (buffer.length) {
data: { await this.prisma.snapshot.create({
id: workspace.id, data: {
workspaceId: workspace.id, id: workspace.id,
blob: buffer, workspaceId: workspace.id,
}, blob: buffer,
}); },
});
}
return workspace; return workspace;
} }
@@ -455,6 +478,7 @@ export class WorkspaceResolver {
} }
} }
@Throttle(10, 30)
@Public() @Public()
@Query(() => InvitationType, { @Query(() => InvitationType, {
description: 'Update workspace', description: 'Update workspace',
@@ -581,6 +605,38 @@ export class WorkspaceResolver {
return this.storage.listBlobs(workspaceId); return this.storage.listBlobs(workspaceId);
} }
@Query(() => WorkspaceBlobSizes)
async collectBlobSizes(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissionProvider.check(workspaceId, user.id);
return this.storage.blobsSize([workspaceId]).then(size => ({ size }));
}
@Query(() => WorkspaceBlobSizes)
async collectAllBlobSizes(@CurrentUser() user: UserType) {
const workspaces = await this.prisma.userWorkspacePermission
.findMany({
where: {
userId: user.id,
accepted: true,
},
select: {
workspace: {
select: {
id: true,
},
},
},
})
.then(data => data.map(({ workspace }) => workspace.id));
const size = await this.storage.blobsSize(workspaces);
return { size };
}
@Mutation(() => String) @Mutation(() => String)
async setBlob( async setBlob(
@CurrentUser() user: UserType, @CurrentUser() user: UserType,
@@ -589,6 +645,12 @@ export class WorkspaceResolver {
blob: FileUpload blob: FileUpload
) { ) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Write); await this.permissionProvider.check(workspaceId, user.id, Permission.Write);
const quota = this.config.objectStorage.quota;
const { size } = await this.collectAllBlobSizes(user);
if (size > quota) {
throw new ForbiddenException('storage size limit exceeded');
}
const buffer = await new Promise<Buffer>((resolve, reject) => { const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream(); const stream = blob.createReadStream();
@@ -602,6 +664,10 @@ export class WorkspaceResolver {
}); });
}); });
if (size + buffer.length > quota) {
throw new ForbiddenException('storage size limit exceeded');
}
return this.storage.uploadBlob(workspaceId, buffer); return this.storage.uploadBlob(workspaceId, buffer);
} }

View File

@@ -123,6 +123,10 @@ type InvitationWorkspaceType {
avatar: String! avatar: String!
} }
type WorkspaceBlobSizes {
size: Int!
}
type InvitationType { type InvitationType {
"""Workspace information""" """Workspace information"""
workspace: InvitationWorkspaceType! workspace: InvitationWorkspaceType!
@@ -149,6 +153,8 @@ type Query {
"""List blobs of workspace""" """List blobs of workspace"""
listBlobs(workspaceId: String!): [String!]! listBlobs(workspaceId: String!): [String!]!
collectBlobSizes(workspaceId: String!): WorkspaceBlobSizes!
collectAllBlobSizes: WorkspaceBlobSizes!
"""Get current user""" """Get current user"""
currentUser: UserType! currentUser: UserType!
@@ -180,8 +186,8 @@ type Mutation {
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList! addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
signUp(name: String!, email: String!, password: String!): UserType! signUp(name: String!, email: String!, password: String!): UserType!
signIn(email: String!, password: String!): UserType! signIn(email: String!, password: String!): UserType!
changePassword(id: String!, newPassword: String!): UserType! changePassword(token: String!, newPassword: String!): UserType!
changeEmail(id: String!, email: String!): UserType! changeEmail(token: String!, email: String!): UserType!
sendChangePasswordEmail(email: String!, callbackUrl: String!): Boolean! sendChangePasswordEmail(email: String!, callbackUrl: String!): Boolean!
sendSetPasswordEmail(email: String!, callbackUrl: String!): Boolean! sendSetPasswordEmail(email: String!, callbackUrl: String!): Boolean!
sendChangeEmail(email: String!, callbackUrl: String!): Boolean! sendChangeEmail(email: String!, callbackUrl: String!): Boolean!

View File

@@ -0,0 +1,60 @@
import KeyvRedis from '@keyv/redis';
import { Global, Injectable, Module } from '@nestjs/common';
import Redis from 'ioredis';
import Keyv from 'keyv';
import { Config } from './config';
@Injectable()
export class SessionService {
private readonly cache: Keyv;
private readonly prefix = 'session:';
private readonly sessionTtl = 30 * 60 * 1000; // 30 min
constructor(protected readonly config: Config) {
if (config.redis.enabled) {
this.cache = new Keyv({
store: new KeyvRedis(
new Redis(config.redis.port, config.redis.host, {
username: config.redis.username,
password: config.redis.password,
db: config.redis.database + 2,
})
),
});
} else {
this.cache = new Keyv();
}
}
/**
* 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, sessionTtl);
}
async delete(key: string) {
return this.cache.delete(this.prefix + key);
}
}
@Global()
@Module({
providers: [SessionService],
exports: [SessionService],
})
export class SessionModule {}

View File

@@ -1,11 +1,11 @@
import { equal, ok } from 'node:assert'; import { equal, ok } from 'node:assert';
import { afterEach, beforeEach, describe, test } from 'node:test';
import { Transformer } from '@napi-rs/image'; import { Transformer } from '@napi-rs/image';
import type { INestApplication } from '@nestjs/common'; import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { hash } from '@node-rs/argon2'; import { hash } from '@node-rs/argon2';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { Express } from 'express'; import { Express } from 'express';
// @ts-expect-error graphql-upload is not typed // @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
@@ -15,83 +15,82 @@ import { AppModule } from '../app';
const gql = '/graphql'; const gql = '/graphql';
describe('AppModule', () => { let app: INestApplication;
let app: INestApplication;
// cleanup database before each test // cleanup database before each test
beforeEach(async () => { test.beforeEach(async () => {
const client = new PrismaClient(); const client = new PrismaClient();
await client.$connect(); await client.$connect();
await client.user.deleteMany({}); await client.user.deleteMany({});
await client.user.create({ await client.user.create({
data: { data: {
name: 'Alex Yang', name: 'Alex Yang',
email: 'alex.yang@example.org', email: 'alex.yang@example.org',
password: await hash('123456'), password: await hash('123456'),
}, },
});
await client.$disconnect();
}); });
await client.$disconnect();
});
beforeEach(async () => { test.beforeEach(async () => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}).compile(); }).compile();
app = module.createNestApplication({ app = module.createNestApplication({
cors: true, cors: true,
bodyParser: true, bodyParser: true,
});
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
}); });
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
afterEach(async () => { test.afterEach(async () => {
await app.close(); await app.close();
}); });
test('should init app', async () => { test('should init app', async () => {
ok(typeof app === 'object'); ok(typeof app === 'object');
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(gql) .post(gql)
.send({ .send({
query: ` query: `
query { query {
error error
} }
`, `,
}) })
.expect(400); .expect(400);
const { token } = await createToken(app); const { token } = await createToken(app);
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(gql) .post(gql)
.auth(token, { type: 'bearer' }) .auth(token, { type: 'bearer' })
.send({ .send({
query: ` query: `
query { query {
__typename __typename
} }
`, `,
}) })
.expect(200) .expect(200)
.expect(res => { .expect(res => {
ok(res.body.data.__typename === 'Query'); ok(res.body.data.__typename === 'Query');
}); });
}); });
test('should find default user', async () => { test('should find default user', async () => {
const { token } = await createToken(app); const { token } = await createToken(app);
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(gql) .post(gql)
.auth(token, { type: 'bearer' }) .auth(token, { type: 'bearer' })
.send({ .send({
query: ` query: `
query { query {
user(email: "alex.yang@example.org") { user(email: "alex.yang@example.org") {
email email
@@ -99,29 +98,29 @@ describe('AppModule', () => {
} }
} }
`, `,
}) })
.expect(200) .expect(200)
.expect(res => { .expect(res => {
equal(res.body.data.user.email, 'alex.yang@example.org'); equal(res.body.data.user.email, 'alex.yang@example.org');
}); });
}); });
test('should be able to upload avatar', async () => { test('should be able to upload avatar', async () => {
const { token, id } = await createToken(app); const { token, id } = await createToken(app);
const png = await Transformer.fromRgbaPixels( const png = await Transformer.fromRgbaPixels(
Buffer.alloc(400 * 400 * 4).fill(255), Buffer.alloc(400 * 400 * 4).fill(255),
400, 400,
400 400
).png(); ).png();
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(gql) .post(gql)
.auth(token, { type: 'bearer' }) .auth(token, { type: 'bearer' })
.field( .field(
'operations', 'operations',
JSON.stringify({ JSON.stringify({
name: 'uploadAvatar', name: 'uploadAvatar',
query: `mutation uploadAvatar($id: String!, $avatar: Upload!) { query: `mutation uploadAvatar($id: String!, $avatar: Upload!) {
uploadAvatar(id: $id, avatar: $avatar) { uploadAvatar(id: $id, avatar: $avatar) {
id id
name name
@@ -130,17 +129,15 @@ describe('AppModule', () => {
} }
} }
`, `,
variables: { id, avatar: null }, variables: { id, avatar: null },
}) })
) )
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
.field('map', JSON.stringify({ '0': ['variables.avatar'] })) .attach('0', png, 'avatar.png')
.attach('0', png, 'avatar.png') .expect(200)
.expect(200) .expect(res => {
.expect(res => { equal(res.body.data.uploadAvatar.id, id);
equal(res.body.data.uploadAvatar.id, id); });
});
});
}); });
async function createToken(app: INestApplication<Express>): Promise<{ async function createToken(app: INestApplication<Express>): Promise<{

View File

@@ -1,9 +1,9 @@
/// <reference types="../global.d.ts" /> /// <reference types="../global.d.ts" />
import { equal } from 'node:assert'; import { equal } from 'node:assert';
import { afterEach, beforeEach, test } from 'node:test';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { ConfigModule } from '../config'; import { ConfigModule } from '../config';
import { GqlModule } from '../graphql.module'; import { GqlModule } from '../graphql.module';
@@ -11,18 +11,19 @@ import { MetricsModule } from '../metrics';
import { AuthModule } from '../modules/auth'; import { AuthModule } from '../modules/auth';
import { AuthService } from '../modules/auth/service'; import { AuthService } from '../modules/auth/service';
import { PrismaModule } from '../prisma'; import { PrismaModule } from '../prisma';
import { RateLimiterModule } from '../throttler';
let auth: AuthService; let auth: AuthService;
let module: TestingModule; let module: TestingModule;
// cleanup database before each test // cleanup database before each test
beforeEach(async () => { test.beforeEach(async () => {
const client = new PrismaClient(); const client = new PrismaClient();
await client.$connect(); await client.$connect();
await client.user.deleteMany({}); await client.user.deleteMany({});
}); });
beforeEach(async () => { test.beforeEach(async () => {
module = await Test.createTestingModule({ module = await Test.createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
@@ -36,21 +37,23 @@ beforeEach(async () => {
GqlModule, GqlModule,
AuthModule, AuthModule,
MetricsModule, MetricsModule,
RateLimiterModule,
], ],
}).compile(); }).compile();
auth = module.get(AuthService); auth = module.get(AuthService);
}); });
afterEach(async () => { test.afterEach(async () => {
await module.close(); await module.close();
}); });
test('should be able to register and signIn', async () => { test('should be able to register and signIn', async t => {
await auth.signUp('Alex Yang', 'alexyang@example.org', '123456'); await auth.signUp('Alex Yang', 'alexyang@example.org', '123456');
await auth.signIn('alexyang@example.org', '123456'); await auth.signIn('alexyang@example.org', '123456');
t.pass();
}); });
test('should be able to verify', async () => { test('should be able to verify', async t => {
await auth.signUp('Alex Yang', 'alexyang@example.org', '123456'); await auth.signUp('Alex Yang', 'alexyang@example.org', '123456');
await auth.signIn('alexyang@example.org', '123456'); await auth.signIn('alexyang@example.org', '123456');
const date = new Date(); const date = new Date();
@@ -81,4 +84,5 @@ test('should be able to verify', async () => {
equal(claim.emailVerified?.toISOString(), date.toISOString()); equal(claim.emailVerified?.toISOString(), date.toISOString());
equal(claim.createdAt.toISOString(), date.toISOString()); equal(claim.createdAt.toISOString(), date.toISOString());
} }
t.pass();
}); });

View File

@@ -1,24 +1,24 @@
import { equal, ok } from 'node:assert'; import { ok } from 'node:assert';
import { beforeEach, test } from 'node:test';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import test from 'ava';
import { Config, ConfigModule } from '../config'; import { Config, ConfigModule } from '../config';
let config: Config; let config: Config;
beforeEach(async () => { test.beforeEach(async () => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
imports: [ConfigModule.forRoot()], imports: [ConfigModule.forRoot()],
}).compile(); }).compile();
config = module.get(Config); config = module.get(Config);
}); });
test('should be able to get config', () => { test('should be able to get config', t => {
ok(typeof config.host === 'string'); t.true(typeof config.host === 'string');
equal(config.env, 'test'); t.is(config.env, 'test');
}); });
test('should be able to override config', async () => { test('should be able to override config', async t => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
@@ -29,4 +29,5 @@ test('should be able to override config', async () => {
const config = module.get(Config); const config = module.get(Config);
ok(config.host, 'testing'); ok(config.host, 'testing');
t.pass();
}); });

Some files were not shown because too many files have changed in this diff Show More