mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 11:58:41 +00:00
Compare commits
71 Commits
v0.9.0-can
...
v0.9.0-can
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f0e67a673 | ||
|
|
138aaed05d | ||
|
|
3edfc46307 | ||
|
|
be9ae57a8e | ||
|
|
4f97ea8a5d | ||
|
|
8825678ca9 | ||
|
|
70b5a9deeb | ||
|
|
eb1a21265f | ||
|
|
8845bb9b4b | ||
|
|
189e91e6ca | ||
|
|
442d06fc69 | ||
|
|
c9c76983de | ||
|
|
3c4f45bcb6 | ||
|
|
db3a6efaf3 | ||
|
|
7d3b1ad2b9 | ||
|
|
e76cdf4d71 | ||
|
|
18ac355df3 | ||
|
|
c0bf82d3ff | ||
|
|
a1f4cbc568 | ||
|
|
10c609348f | ||
|
|
88f94d5b61 | ||
|
|
92f0b31196 | ||
|
|
83e7e9db8d | ||
|
|
3f21b0b45d | ||
|
|
d4a2b3f4d1 | ||
|
|
d4a83c1c6f | ||
|
|
b0024080bd | ||
|
|
c937b88978 | ||
|
|
0f2223ddf0 | ||
|
|
364fc517cc | ||
|
|
25671e2134 | ||
|
|
1e30a3c7fe | ||
|
|
06d5ecd597 | ||
|
|
b18596fc57 | ||
|
|
7082937b62 | ||
|
|
4091ff8e36 | ||
|
|
0fa1bdf7d2 | ||
|
|
df4d71b0c8 | ||
|
|
18d5a99af5 | ||
|
|
6be176b4e3 | ||
|
|
97a0969583 | ||
|
|
a2e4ef904b | ||
|
|
f99a7a5ecd | ||
|
|
f21426d23d | ||
|
|
3f5e649295 | ||
|
|
13857d59dc | ||
|
|
260c25acf3 | ||
|
|
4ef1425299 | ||
|
|
8e48255ef8 | ||
|
|
e10868cd20 | ||
|
|
9bffe3cf24 | ||
|
|
0add43f8db | ||
|
|
cc00da9325 | ||
|
|
49d203ac57 | ||
|
|
55b3182799 | ||
|
|
4e45554585 | ||
|
|
ba735d8b57 | ||
|
|
517f4afb31 | ||
|
|
441e706746 | ||
|
|
7c4e65a5be | ||
|
|
e042152681 | ||
|
|
2e042e03b2 | ||
|
|
d6c0e67bf0 | ||
|
|
e75ff52ec1 | ||
|
|
00e7cf9a06 | ||
|
|
82f8ac50de | ||
|
|
880375a6d1 | ||
|
|
02bd9fc2d1 | ||
|
|
cbb5b6e4a5 | ||
|
|
d3bd369420 | ||
|
|
4aabe2ea5e |
10
.eslintrc.js
10
.eslintrc.js
@@ -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',
|
||||||
|
|||||||
6
.github/actions/deploy/deploy.mjs
vendored
6
.github/actions/deploy/deploy.mjs
vendored
@@ -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}"`,
|
||||||
|
|||||||
11
.github/workflows/build-server.yml
vendored
11
.github/workflows/build-server.yml
vendored
@@ -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
|
||||||
|
|||||||
22
.github/workflows/publish-storybook.yml
vendored
22
.github/workflows/publish-storybook.yml
vendored
@@ -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
489
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
5
apps/core/src/atoms/element.ts
Normal file
5
apps/core/src/atoms/element.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { atom } from 'jotai/vanilla';
|
||||||
|
|
||||||
|
export const appHeaderAtom = atom<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
export const mainContainerAtom = atom<HTMLDivElement | null>(null);
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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*/}
|
||||||
|
|||||||
@@ -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']()}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
53
apps/core/src/components/affine/auth/no-access.tsx
Normal file
53
apps/core/src/components/affine/auth/no-access.tsx
Normal 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])}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
137
apps/core/src/components/affine/auth/use-auth.ts
Normal file
137
apps/core/src/components/affine/auth/use-auth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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']()}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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)': {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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)',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
91
apps/core/src/hooks/use-datasource-sync.ts
Normal file
91
apps/core/src/hooks/use-datasource-sync.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
28
apps/core/src/pages/desktop-signin.tsx
Normal file
28
apps/core/src/pages/desktop-signin.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
63
apps/core/src/utils/cloud-utils.tsx
Normal file
63
apps/core/src/utils/cloud-utils.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 |
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 }>(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
38
apps/server/src/middleware/exception-logger.ts
Normal file
38
apps/server/src/middleware/exception-logger.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%"
|
||||||
|
|||||||
@@ -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`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
3
apps/server/src/modules/users/utils.ts
Normal file
3
apps/server/src/modules/users/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function isStaff(email: string) {
|
||||||
|
return email.endsWith('@toeverything.info');
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
60
apps/server/src/session.ts
Normal file
60
apps/server/src/session.ts
Normal 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 {}
|
||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user