Compare commits

...

71 Commits

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

View File

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

View File

@@ -67,18 +67,20 @@ const createHelmCommand = ({ isDryRun }) => {
const graphqlReplicaCount = isProduction ? 3 : isBeta ? 2 : 2;
const syncReplicaCount = isProduction ? 6 : isBeta ? 3 : 2;
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 = [
`helm upgrade --install affine .github/helm/affine`,
`--namespace ${namespace}`,
`--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}\\" }\"`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
`--set-string global.ingress.host="${DEPLOY_HOST || CANARY_DEPLOY_HOST}"`,
`--set-string global.ingress.host="${host}"`,
...redisAndPostgres,
`--set web.replicaCount=${webReplicaCount}`,
`--set-string web.image.tag="${imageTag}"`,
`--set graphql.replicaCount=${graphqlReplicaCount}`,
`--set-string graphql.image.tag="${imageTag}"`,
`--set graphql.app.host=${host}`,
`--set graphql.app.objectStorage.r2.enabled=true`,
`--set-string graphql.app.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`,
`--set-string graphql.app.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`,

View File

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

View File

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

489
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -77,7 +77,7 @@ Star us, and you will receive all releases notifications from GitHub without any
- **Hyper merged** — Write, draw and plan all at once. Assemble any blocks you love on any canvas you like to enjoy seamless transitions between workflows with AFFiNE.
- **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.
- **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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,35 @@
import {
AuthContent,
BackButton,
CountDownRender,
ModalHeader,
ResendButton,
} from '@affine/component/auth-components';
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 { buildCallbackUrl } from './callback-url';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { useAuth } from './use-auth';
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
setAuthState,
email,
onSignedIn,
}) => {
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 (
<>
@@ -30,15 +43,23 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
{t['com.affine.auth.sign.sent.email.message.end']()}
</AuthContent>
<ResendButton
onClick={useCallback(() => {
signIn('email', {
email: email,
callbackUrl: buildCallbackUrl('signUp'),
redirect: true,
}).catch(console.error);
}, [email])}
/>
<div className={style.resendWrapper}>
{allowSendEmail ? (
<Button type="plain" size="large" onClick={onResendClick}>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
) : (
<>
<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 }}>
{t['com.affine.auth.sign.auth.code.message']()}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,10 @@ import {
MenuTrigger,
styled,
} from '@affine/component';
import { LOCALES } from '@affine/i18n';
import { useI18N } from '@affine/i18n';
import { LOCALES, useI18N } from '@affine/i18n';
import { assertExists } from '@blocksuite/global/utils';
import type { ButtonProps } from '@toeverything/components/button';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
export const StyledListItem = styled(MenuItem)(() => ({
height: '38px',
@@ -17,30 +16,78 @@ export const StyledListItem = styled(MenuItem)(() => ({
}));
interface LanguageMenuContentProps {
currentLanguage?: string;
currentLanguage: string;
currentLanguageIndex: number;
}
const LanguageMenuContent = ({ currentLanguage }: LanguageMenuContentProps) => {
const LanguageMenuContent = ({
currentLanguage,
currentLanguageIndex,
}: LanguageMenuContentProps) => {
const i18n = useI18N();
const changeLanguage = useCallback(
(event: string) => {
return i18n.changeLanguage(event);
(targetLanguage: string) => {
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]
);
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 (
<>
{LOCALES.map(option => {
{LOCALES.map((option, optionIndex) => {
return (
<StyledListItem
key={option.name}
active={currentLanguage === option.originalName}
active={option.tag === currentLanguage}
userFocused={optionIndex == focusedOptionIndex}
title={option.name}
onClick={() => {
changeLanguage(option.tag).catch(err => {
throw new Error('Failed to change language', err);
});
changeLanguage(option.tag);
}}
>
{option.originalName}
@@ -61,16 +108,19 @@ export const LanguageMenu = ({
}: LanguageMenuProps) => {
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 (
<Menu
content={
(
<LanguageMenuContent
currentLanguage={currentLanguage?.originalName}
/>
) as ReactElement
<LanguageMenuContent
currentLanguage={currentLanguage.tag}
currentLanguageIndex={currentLanguageIndex}
/>
}
placement="bottom-end"
trigger="click"
@@ -82,7 +132,7 @@ export const LanguageMenu = ({
style={{ textTransform: 'capitalize' }}
{...triggerProps}
>
{currentLanguage?.originalName}
{currentLanguage.originalName}
</MenuTrigger>
</Menu>
);

View File

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

View File

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

View File

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

View File

@@ -2,19 +2,20 @@ import { FlexWrapper, Input } from '@affine/component';
import {
SettingHeader,
SettingRow,
StorageProgress,
} from '@affine/component/setting-components';
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 { useMutation } from '@affine/workspace/affine/gql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon, CameraIcon, DoneIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useAtom } from 'jotai/index';
import { signOut } from 'next-auth/react';
import { type FC, useCallback, useState } from 'react';
import { useSetAtom } from 'jotai';
import { type FC, Suspense, useCallback, useState } from 'react';
import { authAtom } from '../../../../atoms';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { signOutCloud } from '../../../../utils/cloud-utils';
import { Upload } from '../../../pure/file-upload';
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 = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const [, setAuthModal] = useAtom(authAtom);
const setAuthModal = useSetAtom(authAtom);
const onChangeEmail = useCallback(() => {
setAuthModal({
@@ -122,14 +147,15 @@ export const AccountSetting: FC = () => {
emailType: 'changeEmail',
});
}, [setAuthModal, user.email]);
const onChangePassword = useCallback(() => {
const onPasswordButtonClick = useCallback(() => {
setAuthModal({
openModal: true,
state: 'sendEmail',
email: user.email,
emailType: 'changePassword',
emailType: user.hasPassword ? 'changePassword' : 'setPassword',
});
}, [setAuthModal, user.email]);
}, [setAuthModal, user.email, user.hasPassword]);
return (
<>
@@ -148,19 +174,21 @@ export const AccountSetting: FC = () => {
name={t['com.affine.settings.password']()}
desc={t['com.affine.settings.password.message']()}
>
<Button onClick={onChangePassword}>
<Button onClick={onPasswordButtonClick}>
{user.hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<Suspense>
<StoragePanel />
</Suspense>
<SettingRow
name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()}
style={{ cursor: 'pointer' }}
onClick={useCallback(() => {
signOut().catch(console.error);
signOutCloud().catch(console.error);
}, [])}
>
<ArrowRightSmallIcon />

View File

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

View File

@@ -24,11 +24,8 @@ export const shortcutKey = style({
alignItems: 'center',
justifyContent: 'center',
padding: '0 6px',
border: '1px solid var(--affine-border-color)',
borderRadius: '4px',
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)',
selectors: {
'&:not(:last-of-type)': {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,116 +1,43 @@
import { Wrapper } from '@affine/component';
import {
appSidebarFloatingAtom,
appSidebarOpenAtom,
SidebarSwitch,
} from '@affine/component/app-sidebar';
import { isDesktop } from '@affine/env/constant';
import { useIsTinyScreen } from '@toeverything/hooks/use-is-tiny-screen';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import debounce from 'lodash.debounce';
import type { MutableRefObject, ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { type Atom, useAtomValue } from 'jotai';
import type { ReactElement } from 'react';
import { forwardRef, useRef } from 'react';
import * as style from './style.css';
import { TopTip } from './top-tip';
import { WindowsAppControls } from './windows-app-controls';
interface HeaderPros {
left?: ReactNode;
right?: ReactNode;
center?: ReactNode;
left?: ReactElement;
right?: ReactElement;
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
// 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
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 leftSlotRef = useRef<HTMLDivElement | null>(null);
const centerSlotRef = useRef<HTMLDivElement | null>(null);
const rightSlotRef = useRef<HTMLDivElement | null>(null);
const windowControlsRef = useRef<HTMLDivElement | null>(null);
const mainContainer = useAtomValue(mainContainerAtom);
const isTinyScreen = useIsTinyScreen({
mainContainer: document.querySelector('.main-container') || document.body,
mainContainer,
leftStatic: sidebarSwitchRef,
leftSlot: [leftSlotRef],
centerDom: centerSlotRef,
@@ -130,6 +57,7 @@ export const Header = ({ left, center, right }: HeaderPros) => {
data-open={open}
data-sidebar-floating={appSidebarFloating}
data-testid="header"
ref={ref}
>
<div
className={clsx(style.headerSideContainer, {
@@ -137,12 +65,8 @@ export const Header = ({ left, center, right }: HeaderPros) => {
})}
>
<div className={clsx(style.headerItem, 'top-item')}>
<div ref={sidebarSwitchRef}>
{!open && (
<Wrapper marginRight={20}>
<SidebarSwitch />
</Wrapper>
)}
<div ref={sidebarSwitchRef} style={{ marginRight: open ? 0 : 20 }}>
<SidebarSwitch show={!open} />
</div>
</div>
<div className={clsx(style.headerItem, 'left')}>
@@ -175,4 +99,6 @@ export const Header = ({ left, center, right }: HeaderPros) => {
</div>
</>
);
};
});
Header.displayName = 'Header';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,27 @@
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { 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 type React from 'react';
import { useCallback } from 'react';
import { atom, useAtomValue, useSetAtom } from 'jotai';
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 { workspaceAvatarStyle } from './index.css';
import { Loading } from './loading-icon';
import {
StyledSelectorContainer,
StyledSelectorWrapper,
@@ -18,6 +34,133 @@ export interface WorkspaceSelectorProps {
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},
* because it's never used elsewhere.
@@ -29,12 +172,11 @@ export const WorkspaceSelector = ({
const [name] = useBlockSuiteWorkspaceName(
currentWorkspace.blockSuiteWorkspace
);
// 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: Delete this?
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// TODO-Doma Rename this callback to `onOpenDialog` or something to reduce ambiguity.
@@ -43,6 +185,7 @@ export const WorkspaceSelector = ({
},
[onClick]
);
const isHovered = useAtomValue(hoverAtom);
return (
<StyledSelectorContainer
@@ -50,6 +193,7 @@ export const WorkspaceSelector = ({
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
disableHoverBackground={isHovered}
data-testid="current-workspace"
id="current-workspace"
>
@@ -63,14 +207,7 @@ export const WorkspaceSelector = ({
<StyledWorkspaceName data-testid="workspace-name">
{name}
</StyledWorkspaceName>
<StyledWorkspaceStatus>
{currentWorkspace.flavour === 'local' ? (
<LocalWorkspaceIcon />
) : (
<CloudWorkspaceIcon />
)}
{currentWorkspace.flavour === 'local' ? 'Local' : 'AFFiNE Cloud'}
</StyledWorkspaceStatus>
<WorkspaceStatus currentWorkspace={currentWorkspace} />
</StyledSelectorWrapper>
</StyledSelectorContainer>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,14 +33,16 @@ import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/reac
import { currentWorkspaceIdAtom } from '@toeverything/infra/atom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
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 { Map as YMap } from 'yjs';
import {
openQuickSearchModalAtom,
openSettingModalAtom,
openWorkspacesModalAtom,
} from '../atoms';
import { mainContainerAtom } from '../atoms/element';
import { useAppSetting } from '../atoms/settings';
import { AdapterProviderWrapper } from '../components/adapter-worksapce-wrapper';
import { AppContainer } from '../components/affine/app-container';
@@ -133,6 +135,26 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
const [currentWorkspace] = useCurrentWorkspace();
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);
const [, setOpenWorkspacesModal] = useAtom(openWorkspacesModalAtom);
@@ -206,6 +228,8 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
const location = useLocation();
const { pageId } = useParams();
const setMainContainer = useSetAtom(mainContainerAtom);
return (
<>
{/* 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}
/>
</Suspense>
<Suspense fallback={<MainContainer />}>
<MainContainer padding={appSetting.clientBorder}>
<Suspense fallback={<MainContainer ref={setMainContainer} />}>
<MainContainer
ref={setMainContainer}
padding={appSetting.clientBorder}
>
{children}
<ToolContainer>
<BlockHubWrapper blockHubAtom={rootBlockHubAtom} />

View File

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

View File

@@ -0,0 +1,28 @@
import type { LoaderFunction } from 'react-router-dom';
import { z } from 'zod';
import { signInCloud } from '../utils/cloud-utils';
const supportedProvider = z.enum(['google']);
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const searchParams = url.searchParams;
const provider = searchParams.get('provider');
const callback_url = searchParams.get('callback_url');
if (!callback_url) {
return null;
}
const maybeProvider = supportedProvider.safeParse(provider);
if (maybeProvider.success) {
const provider = maybeProvider.data;
await signInCloud(provider, {
callbackUrl: callback_url,
});
}
return null;
};
export const Component = () => {
return null;
};

View File

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

View File

@@ -1,9 +1,15 @@
import { type GetCurrentUserQuery, getCurrentUserQuery } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { fetcher } from '@affine/workspace/affine/gql';
import { Logo1Icon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
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 * as styles from './open-app.css';
@@ -45,107 +51,164 @@ const appNames = {
internal: 'AFFiNE Internal',
} 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 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 urlToOpen = useMemo(() => params.get('url'), [params]);
const autoOpen = useMemo(() => params.get('open') !== 'false', [params]);
const channel = useMemo(() => {
const urlObj = new URL(urlToOpen || '');
const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
return schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
}, [urlToOpen]);
const appIcon = appIconMap[channel];
const appName = appNames[channel];
return <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
};
const openDownloadLink = useCallback(() => {
const url = `https://affine.pro/download?channel=${channel}`;
open(url, '_blank');
}, [channel]);
const OpenOAuthJwt = () => {
const { currentUser } = useLoaderData() as LoaderData;
const [params] = useSearchParams();
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 (!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 {
if (!currentUser || !currentUser?.token?.token) {
return null;
}
const urlToOpen = `${schema}://oauth-jwt?token=${currentUser.token.token}`;
return <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
};
export const Component = () => {
const { action } = useLoaderData() as LoaderData;
if (action === 'url') {
return <OpenUrl />;
} else if (action === 'oauth-jwt') {
return <OpenOAuthJwt />;
}
return null;
};
export const loader: LoaderFunction = async args => {
const action = args.params.action || '';
const res = await fetcher({
query: getCurrentUserQuery,
}).catch(console.error);
return {
action,
currentUser: res?.currentUser || null,
};
};

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,19 @@
import type { ToastOptions } from '@affine/component';
import { 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) => {
const mainContainer = getCurrentStore().get(mainContainerAtom);
const modal = document.querySelector(
'[role=presentation]'
) as HTMLElement | null;
const mainContainer = document.querySelector(
'.main-container'
) as HTMLElement | null;
) as HTMLDivElement | null;
assertExists(mainContainer, 'main container should exist');
if (modal) {
assertEquals(modal.constructor, HTMLDivElement, 'modal should be div');
}
return basicToast(message, {
portal: modal || mainContainer || document.body,
...options,

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 761 KiB

View File

@@ -45,6 +45,22 @@ cd(repoRootDir);
if (!process.env.SKIP_WEB_BUILD) {
await $`yarn -T run build:plugins`;
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 });
}

View File

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

View File

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

View File

@@ -2,11 +2,13 @@ import path from 'node:path';
import type { App } from 'electron';
import { buildType, isDev } from './config';
import { buildType, CLOUD_BASE_URL, isDev } from './config';
import { logger } from './logger';
import {
handleOpenUrlInHiddenWindow,
mainWindowOrigin,
restoreOrCreateWindow,
setCookie,
} from './main-window';
import { uiSubjects } from './ui';
@@ -57,47 +59,55 @@ async function handleAffineUrl(url: string) {
logger.info('handle affine schema action', urlObj.hostname);
// handle more actions here
// hostname is the action name
if (urlObj.hostname === 'sign-in') {
const urlToOpen = urlObj.search.slice(1);
if (urlToOpen) {
await handleSignIn(urlToOpen);
}
if (urlObj.hostname === 'oauth-jwt') {
await handleOauthJwt(url);
}
}
// todo: move to another place?
async function handleSignIn(url: string) {
async function handleOauthJwt(url: string) {
if (url) {
try {
const mainWindow = await restoreOrCreateWindow();
mainWindow.show();
const urlObj = new URL(url);
const email = urlObj.searchParams.get('email');
const token = urlObj.searchParams.get('token');
if (!email) {
logger.error('no email in url', url);
if (!token) {
logger.error('no token in url', url);
return;
}
uiSubjects.onStartLogin.next({
email,
const isSecure = CLOUD_BASE_URL.startsWith('https://');
// set token to cookie
await setCookie({
url: CLOUD_BASE_URL,
httpOnly: true,
value: token,
secure: true,
name: isSecure
? '__Secure-next-auth.session-token'
: 'next-auth.session-token',
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(() => {
window.destroy();
}, 3000);
uiSubjects.onFinishLogin.next({
success: ['/auth/signIn', '/auth/signUp'].includes(finalUrl.pathname),
email,
});
} catch (e) {
logger.error('failed to open url in popup', e);
}

View File

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

View File

@@ -2,8 +2,21 @@ import { net, protocol, session } from 'electron';
import { join } from 'path';
import { CLOUD_BASE_URL } from './config';
import { setCookie } from './main-window';
import { simpleGet } from './utils';
import { logger } from './logger';
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 webStaticDir = join(__dirname, '../resources/web-static');
@@ -12,42 +25,20 @@ function isNetworkResource(pathname: string) {
return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt));
}
async function handleHttpRequest(request: Request) {
async function handleFileRequest(request: Request) {
const clonedRequest = Object.assign(request.clone(), {
bypassCustomProtocolHandlers: true,
});
const { pathname, origin } = new URL(request.url);
if (
!origin.startsWith(CLOUD_BASE_URL) ||
isNetworkResource(pathname) ||
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);
}
const urlObject = new URL(request.url);
if (isNetworkResource(urlObject.pathname)) {
// just pass through (proxy)
return net.fetch(CLOUD_BASE_URL + urlObject.pathname, clonedRequest);
} else {
// this will be file types (in the web-static folder)
let filepath = '';
// if is a file type, load the file in resources
if (pathname.split('/').at(-1)?.includes('.')) {
filepath = join(webStaticDir, decodeURIComponent(pathname));
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
filepath = join(webStaticDir, decodeURIComponent(urlObject.pathname));
} else {
// else, fallback to load the index.html instead
filepath = join(webStaticDir, 'index.html');
@@ -57,12 +48,12 @@ async function handleHttpRequest(request: Request) {
}
export function registerProtocol() {
protocol.handle('http', request => {
return handleHttpRequest(request);
protocol.handle('file', request => {
return handleFileRequest(request);
});
protocol.handle('https', request => {
return handleHttpRequest(request);
protocol.handle('assets', request => {
return handleFileRequest(request);
});
// hack for CORS
@@ -81,9 +72,49 @@ export function registerProtocol() {
'DELETE',
'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 });
}
);
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
(async () => {
const url = new URL(details.url);
const pathname = url.pathname;
// if sending request to the cloud, attach the session cookie
if (isNetworkResource(pathname)) {
const cookie = await getCookie(CLOUD_BASE_URL);
const cookieString = cookie.map(c => `${c.name}=${c.value}`).join('; ');
details.requestHeaders['cookie'] = cookieString;
}
callback({
cancel: false,
requestHeaders: details.requestHeaders,
});
})().catch(e => {
logger.error('failed to attach cookie', e);
callback({
cancel: false,
requestHeaders: details.requestHeaders,
});
});
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -157,6 +157,12 @@ export interface AFFiNEConfig {
* the apollo driver config
*/
graphql: ApolloDriverConfig;
/**
* app features flag
*/
featureFlags: {
earlyAccessPreview: boolean;
};
/**
* object storage Config
*
@@ -180,7 +186,31 @@ export interface AFFiNEConfig {
fs: {
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
*
@@ -201,6 +231,15 @@ export interface AFFiNEConfig {
port: number;
username: string;
password: string;
/**
* redis database index
*
* Rate Limiter scope: database + 1
*
* Session scope: database + 2
*
* @default 0
*/
database: number;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,34 +42,45 @@ export class MailService {
};
}
) {
console.log('invitationInfo', invitationInfo);
const buttonUrl = `${this.config.baseUrl}/invite/${inviteId}`;
// TODO: use callback url when need support desktop app
const buttonUrl = `${this.config.origin}/invite/${inviteId}`;
const workspaceAvatar = invitationInfo.workspace.avatar;
const content = ` <img
const content = `${
invitationInfo.user.avatar
? `<img
src="${invitationInfo.user.avatar}"
alt=""
width="24px"
height="24px"
style="border-radius: 12px;object-fit: cover;vertical-align: middle"
/>
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.user.name}</span>
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>invited you to join</span>
<img
src="cid:workspaceAvatar"
alt=""
width="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({
title: 'You are invited!',
content,
buttonContent: 'Accept & Join',
buttonUrl,
subContent,
});
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) {
const html = `
<h1>Change password</h1>
<p>Click button to open change password page</p>
<a href="${url}">${url}</a>
`;
const html = emailTemplate({
title: 'Modify your AFFiNE password',
content:
'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({
from: this.config.auth.email.sender,
to,
subject: `Change password`,
subject: `Modify your AFFiNE password`,
html,
});
}
async sendSetPasswordEmail(to: string, url: string) {
const html = `
<h1>Set password</h1>
<p>Click button to open set password page</p>
<a href="${url}">${url}</a>
`;
const html = emailTemplate({
title: 'Set your AFFiNE password',
content:
'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({
from: this.config.auth.email.sender,
to,
subject: `Change password`,
subject: `Set your AFFiNE password`,
html,
});
}
async sendChangeEmail(to: string, url: string) {
const html = `
<h1>Change Email</h1>
<p>Click button to open change email page</p>
<a href="${url}">${url}</a>
`;
const html = emailTemplate({
title: 'Verify your current email for AFFiNE',
content:
'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({
from: this.config.auth.email.sender,
to,
subject: `Change password`,
subject: `Verify your current email for AFFiNE`,
html,
});
}

View File

@@ -3,11 +3,13 @@ export const emailTemplate = ({
content,
buttonContent,
buttonUrl,
subContent,
}: {
title: string;
content: string;
buttonContent: string;
buttonUrl: string;
subContent?: string;
}) => {
return `<body style="background: #f6f7fb; overflow: hidden">
<table
@@ -58,7 +60,9 @@ export const emailTemplate = ({
>${content}</td>
</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">
<tr>
<td style="border-radius: 8px" bgcolor="#1E96EB">
@@ -85,6 +89,24 @@ export const emailTemplate = ({
</table>
</td>
</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
width="100%"

View File

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

View File

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

View File

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

View File

@@ -67,8 +67,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
protected recoverDoc(...updates: Buffer[]): Doc {
const doc = new Doc();
updates.forEach(update => {
applyUpdate(doc, update);
updates.forEach((update, i) => {
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;

View File

@@ -105,6 +105,8 @@ export class RedisDocManager extends DocManager {
.catch(() => null); // safe;
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;
}
@@ -141,7 +143,10 @@ export class RedisDocManager extends DocManager {
this.logger.error('Failed to remove merged updates from Redis', 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
} finally {
await this.redis.del(lockKey);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,10 @@
import type { Storage } from '@affine/storage';
import { ForbiddenException, Inject, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Inject,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import {
Args,
Field,
@@ -22,8 +27,10 @@ import type { User, Workspace } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public } from '../auth';
import { MailService } from '../auth/mailer';
@@ -89,6 +96,12 @@ export class InvitationWorkspaceType {
avatar!: string;
}
@ObjectType()
export class WorkspaceBlobSizes {
@Field(() => Int)
size!: number;
}
@ObjectType()
export class InvitationType {
@Field({ description: 'Workspace information' })
@@ -107,11 +120,18 @@ export class UpdateWorkspaceInput extends PickType(
id!: string;
}
/**
* Workspace resolver
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
constructor(
private readonly auth: AuthService,
private readonly config: Config,
private readonly mailer: MailService,
private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService,
@@ -252,10 +272,11 @@ export class WorkspaceResolver {
});
}
@Throttle(10, 30)
@Public()
@Query(() => WorkspaceType, {
description: 'Get public workspace by id',
})
@Public()
async publicWorkspace(@Args('id') id: string) {
const workspace = await this.prisma.workspace.findUnique({
where: { id },
@@ -320,13 +341,15 @@ export class WorkspaceResolver {
},
});
await this.prisma.snapshot.create({
data: {
id: workspace.id,
workspaceId: workspace.id,
blob: buffer,
},
});
if (buffer.length) {
await this.prisma.snapshot.create({
data: {
id: workspace.id,
workspaceId: workspace.id,
blob: buffer,
},
});
}
return workspace;
}
@@ -455,6 +478,7 @@ export class WorkspaceResolver {
}
}
@Throttle(10, 30)
@Public()
@Query(() => InvitationType, {
description: 'Update workspace',
@@ -581,6 +605,38 @@ export class WorkspaceResolver {
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)
async setBlob(
@CurrentUser() user: UserType,
@@ -589,6 +645,12 @@ export class WorkspaceResolver {
blob: FileUpload
) {
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 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);
}

View File

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

View File

@@ -0,0 +1,60 @@
import KeyvRedis from '@keyv/redis';
import { Global, Injectable, Module } from '@nestjs/common';
import Redis from 'ioredis';
import Keyv from 'keyv';
import { Config } from './config';
@Injectable()
export class SessionService {
private readonly cache: Keyv;
private readonly prefix = 'session:';
private readonly sessionTtl = 30 * 60 * 1000; // 30 min
constructor(protected readonly config: Config) {
if (config.redis.enabled) {
this.cache = new Keyv({
store: new KeyvRedis(
new Redis(config.redis.port, config.redis.host, {
username: config.redis.username,
password: config.redis.password,
db: config.redis.database + 2,
})
),
});
} else {
this.cache = new Keyv();
}
}
/**
* get session
* @param key session key
* @returns
*/
async get(key: string) {
return this.cache.get(this.prefix + key);
}
/**
* set session
* @param key session key
* @param value session value
* @param sessionTtl session ttl (ms), default 30 min
* @returns return true if success
*/
async set(key: string, value?: any, sessionTtl = this.sessionTtl) {
return this.cache.set(this.prefix + key, value, sessionTtl);
}
async delete(key: string) {
return this.cache.delete(this.prefix + key);
}
}
@Global()
@Module({
providers: [SessionService],
exports: [SessionService],
})
export class SessionModule {}

View File

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

View File

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

View File

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

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