mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-10 19:38:39 +00:00
Compare commits
71 Commits
v0.9.0-can
...
v0.9.0-can
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f0e67a673 | ||
|
|
138aaed05d | ||
|
|
3edfc46307 | ||
|
|
be9ae57a8e | ||
|
|
4f97ea8a5d | ||
|
|
8825678ca9 | ||
|
|
70b5a9deeb | ||
|
|
eb1a21265f | ||
|
|
8845bb9b4b | ||
|
|
189e91e6ca | ||
|
|
442d06fc69 | ||
|
|
c9c76983de | ||
|
|
3c4f45bcb6 | ||
|
|
db3a6efaf3 | ||
|
|
7d3b1ad2b9 | ||
|
|
e76cdf4d71 | ||
|
|
18ac355df3 | ||
|
|
c0bf82d3ff | ||
|
|
a1f4cbc568 | ||
|
|
10c609348f | ||
|
|
88f94d5b61 | ||
|
|
92f0b31196 | ||
|
|
83e7e9db8d | ||
|
|
3f21b0b45d | ||
|
|
d4a2b3f4d1 | ||
|
|
d4a83c1c6f | ||
|
|
b0024080bd | ||
|
|
c937b88978 | ||
|
|
0f2223ddf0 | ||
|
|
364fc517cc | ||
|
|
25671e2134 | ||
|
|
1e30a3c7fe | ||
|
|
06d5ecd597 | ||
|
|
b18596fc57 | ||
|
|
7082937b62 | ||
|
|
4091ff8e36 | ||
|
|
0fa1bdf7d2 | ||
|
|
df4d71b0c8 | ||
|
|
18d5a99af5 | ||
|
|
6be176b4e3 | ||
|
|
97a0969583 | ||
|
|
a2e4ef904b | ||
|
|
f99a7a5ecd | ||
|
|
f21426d23d | ||
|
|
3f5e649295 | ||
|
|
13857d59dc | ||
|
|
260c25acf3 | ||
|
|
4ef1425299 | ||
|
|
8e48255ef8 | ||
|
|
e10868cd20 | ||
|
|
9bffe3cf24 | ||
|
|
0add43f8db | ||
|
|
cc00da9325 | ||
|
|
49d203ac57 | ||
|
|
55b3182799 | ||
|
|
4e45554585 | ||
|
|
ba735d8b57 | ||
|
|
517f4afb31 | ||
|
|
441e706746 | ||
|
|
7c4e65a5be | ||
|
|
e042152681 | ||
|
|
2e042e03b2 | ||
|
|
d6c0e67bf0 | ||
|
|
e75ff52ec1 | ||
|
|
00e7cf9a06 | ||
|
|
82f8ac50de | ||
|
|
880375a6d1 | ||
|
|
02bd9fc2d1 | ||
|
|
cbb5b6e4a5 | ||
|
|
d3bd369420 | ||
|
|
4aabe2ea5e |
10
.eslintrc.js
10
.eslintrc.js
@@ -37,6 +37,11 @@ const createPattern = packageName => [
|
||||
// useSession is type unsafe
|
||||
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',
|
||||
|
||||
6
.github/actions/deploy/deploy.mjs
vendored
6
.github/actions/deploy/deploy.mjs
vendored
@@ -67,18 +67,20 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
const graphqlReplicaCount = isProduction ? 3 : isBeta ? 2 : 2;
|
||||
const 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}"`,
|
||||
|
||||
11
.github/workflows/build-server.yml
vendored
11
.github/workflows/build-server.yml
vendored
@@ -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
|
||||
|
||||
22
.github/workflows/publish-storybook.yml
vendored
22
.github/workflows/publish-storybook.yml
vendored
@@ -4,15 +4,19 @@ env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
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
489
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,7 @@ Star us, and you will receive all releases notifications from GitHub without any
|
||||
|
||||
- **Hyper merged** — Write, draw and plan all at once. Assemble any blocks you love on any canvas you like to enjoy seamless transitions between workflows with AFFiNE.
|
||||
- **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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
apps/core/src/atoms/element.ts
Normal file
5
apps/core/src/atoms/element.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { atom } from 'jotai/vanilla';
|
||||
|
||||
export const appHeaderAtom = atom<HTMLDivElement | null>(null);
|
||||
|
||||
export const mainContainerAtom = atom<HTMLDivElement | null>(null);
|
||||
@@ -49,7 +49,7 @@ export const fontStyleOptions = [
|
||||
}[];
|
||||
|
||||
const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
|
||||
clientBorder: globalThis.platform !== 'win32',
|
||||
clientBorder: environment.isDesktop && globalThis.platform !== 'win32',
|
||||
fullWidthLayout: false,
|
||||
windowFrameStyle: 'frameless',
|
||||
fontStyle: 'Sans',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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*/}
|
||||
|
||||
@@ -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']()}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { isDesktop } from '@affine/env/constant';
|
||||
|
||||
type Action = 'signUp' | 'changePassword' | 'signIn' | 'signUp';
|
||||
|
||||
export function buildCallbackUrl(action: Action) {
|
||||
const callbackUrl = `/auth/${action}`;
|
||||
const params: string[][] = [];
|
||||
if (isDesktop && window.appInfo.schema) {
|
||||
params.push(['schema', window.appInfo.schema]);
|
||||
}
|
||||
const query =
|
||||
params.length > 0
|
||||
? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||
: '';
|
||||
return callbackUrl + query;
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import {
|
||||
AuthModal as AuthModalBase,
|
||||
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]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
53
apps/core/src/components/affine/auth/no-access.tsx
Normal file
53
apps/core/src/components/affine/auth/no-access.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AuthContent,
|
||||
BackButton,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { NewIcon } from '@blocksuite/icons';
|
||||
import { type FC, useCallback } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
|
||||
export const NoAccess: FC<AuthPanelProps> = ({ setAuthState, onSignedIn }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t['AFFiNE Cloud']()}
|
||||
subTitle={t['Early Access Stage']()}
|
||||
/>
|
||||
<AuthContent style={{ height: 162 }}>
|
||||
{t['com.affine.auth.sign.no.access.hint']()}
|
||||
<a href="https://community.affine.pro/c/insider-general/">
|
||||
{t['com.affine.auth.sign.no.access.link']()}
|
||||
</a>
|
||||
</AuthContent>
|
||||
|
||||
<div className={style.accessMessage}>
|
||||
<NewIcon
|
||||
style={{
|
||||
fontSize: 16,
|
||||
marginRight: 4,
|
||||
color: 'var(--affine-icon-color)',
|
||||
}}
|
||||
/>
|
||||
{t['com.affine.auth.sign.no.access.wait']()}
|
||||
</div>
|
||||
|
||||
<BackButton
|
||||
onClick={useCallback(() => {
|
||||
setAuthState('signIn');
|
||||
}, [setAuthState])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useMutation } from '@affine/workspace/affine/gql';
|
||||
import { 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 (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
137
apps/core/src/components/affine/auth/use-auth.ts
Normal file
137
apps/core/src/components/affine/auth/use-auth.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import type { Notification } from '@affine/component/notification-center/index.jotai';
|
||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||
import { type SignInResponse } from 'next-auth/react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { signInCloud } from '../../../utils/cloud-utils';
|
||||
|
||||
const COUNT_DOWN_TIME = 60;
|
||||
export const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
|
||||
|
||||
function handleSendEmailError(
|
||||
res: SignInResponse | undefined | void,
|
||||
pushNotification: (notification: Notification) => void
|
||||
) {
|
||||
if (res?.error) {
|
||||
pushNotification({
|
||||
title: 'Send email error',
|
||||
message: 'Please back to home and try again',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type AuthStoreAtom = {
|
||||
allowSendEmail: boolean;
|
||||
resendCountDown: number;
|
||||
isMutating: boolean;
|
||||
};
|
||||
|
||||
export const authStoreAtom = atom<AuthStoreAtom>({
|
||||
isMutating: false,
|
||||
allowSendEmail: true,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
|
||||
const countDownAtom = atom(
|
||||
null, // it's a convention to pass `null` for the first argument
|
||||
(get, set) => {
|
||||
const clearId = window.setInterval(() => {
|
||||
const countDown = get(authStoreAtom).resendCountDown;
|
||||
if (countDown === 0) {
|
||||
set(authStoreAtom, {
|
||||
isMutating: false,
|
||||
allowSendEmail: true,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
window.clearInterval(clearId);
|
||||
return;
|
||||
}
|
||||
set(authStoreAtom, {
|
||||
isMutating: false,
|
||||
resendCountDown: countDown - 1,
|
||||
allowSendEmail: false,
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
);
|
||||
|
||||
export const useAuth = () => {
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const [authStore, setAuthStore] = useAtom(authStoreAtom);
|
||||
const startResendCountDown = useSetAtom(countDownAtom);
|
||||
|
||||
const signIn = useCallback(
|
||||
async (email: string) => {
|
||||
setAuthStore(prev => {
|
||||
return {
|
||||
...prev,
|
||||
isMutating: true,
|
||||
};
|
||||
});
|
||||
|
||||
const res = await signInCloud('email', {
|
||||
email: email,
|
||||
callbackUrl: '/auth/signIn',
|
||||
redirect: false,
|
||||
}).catch(console.error);
|
||||
|
||||
handleSendEmailError(res, pushNotification);
|
||||
|
||||
setAuthStore({
|
||||
isMutating: false,
|
||||
allowSendEmail: false,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
|
||||
startResendCountDown();
|
||||
|
||||
return res;
|
||||
},
|
||||
[pushNotification, setAuthStore, startResendCountDown]
|
||||
);
|
||||
|
||||
const signUp = useCallback(
|
||||
async (email: string) => {
|
||||
setAuthStore(prev => {
|
||||
return {
|
||||
...prev,
|
||||
isMutating: true,
|
||||
};
|
||||
});
|
||||
|
||||
const res = await signInCloud('email', {
|
||||
email: email,
|
||||
callbackUrl: '/auth/signUp',
|
||||
redirect: false,
|
||||
}).catch(console.error);
|
||||
|
||||
handleSendEmailError(res, pushNotification);
|
||||
|
||||
setAuthStore({
|
||||
isMutating: false,
|
||||
allowSendEmail: false,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
|
||||
startResendCountDown();
|
||||
|
||||
return res;
|
||||
},
|
||||
[pushNotification, setAuthStore, startResendCountDown]
|
||||
);
|
||||
|
||||
const signInWithGoogle = useCallback(() => {
|
||||
signInCloud('google').catch(console.error);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
allowSendEmail: authStore.allowSendEmail,
|
||||
resendCountDown: authStore.resendCountDown,
|
||||
isMutating: authStore.isMutating,
|
||||
signUp,
|
||||
signIn,
|
||||
signInWithGoogle,
|
||||
};
|
||||
};
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
MenuTrigger,
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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']()}>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type WorkspaceSettingDetailProps } from './index';
|
||||
import * as style from './style.css';
|
||||
|
||||
export interface LabelsPanelProps extends WorkspaceSettingDetailProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
|
||||
type WorkspaceStatus =
|
||||
| 'local'
|
||||
| 'syncCloud'
|
||||
| 'syncDocker'
|
||||
| 'selfHosted'
|
||||
| 'joinedWorkspace'
|
||||
| 'availableOffline'
|
||||
| 'publishedToWeb';
|
||||
|
||||
type LabelProps = {
|
||||
value: string;
|
||||
background: string;
|
||||
};
|
||||
|
||||
type LabelMap = {
|
||||
[key in WorkspaceStatus]: LabelProps;
|
||||
};
|
||||
type labelConditionsProps = {
|
||||
condition: boolean;
|
||||
label: WorkspaceStatus;
|
||||
};
|
||||
const Label = ({ value, background }: LabelProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div className={style.workspaceLabel} style={{ background: background }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const LabelsPanel = ({ workspace, isOwner }: LabelsPanelProps) => {
|
||||
const labelMap: LabelMap = useMemo(
|
||||
() => ({
|
||||
local: {
|
||||
value: 'Local',
|
||||
background: 'var(--affine-tag-orange)',
|
||||
},
|
||||
syncCloud: {
|
||||
value: 'Sync with AFFiNE Cloud',
|
||||
background: 'var(--affine-tag-blue)',
|
||||
},
|
||||
syncDocker: {
|
||||
value: 'Sync with AFFiNE Docker',
|
||||
background: 'var(--affine-tag-green)',
|
||||
},
|
||||
selfHosted: {
|
||||
value: 'Self-Hosted Server',
|
||||
background: 'var(--affine-tag-purple)',
|
||||
},
|
||||
joinedWorkspace: {
|
||||
value: 'Joined Workspace',
|
||||
background: 'var(--affine-tag-yellow)',
|
||||
},
|
||||
availableOffline: {
|
||||
value: 'Available Offline',
|
||||
background: 'var(--affine-tag-green)',
|
||||
},
|
||||
publishedToWeb: {
|
||||
value: 'Published to Web',
|
||||
background: 'var(--affine-tag-blue)',
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const labelConditions: labelConditionsProps[] = [
|
||||
{ condition: !isOwner, label: 'joinedWorkspace' },
|
||||
{ condition: workspace.flavour === 'local', label: 'local' },
|
||||
{ condition: workspace.flavour === 'affine-cloud', label: 'syncCloud' },
|
||||
{
|
||||
condition: workspace.flavour === 'affine-public',
|
||||
label: 'publishedToWeb',
|
||||
},
|
||||
//TODO: add these labels
|
||||
// { status==="synced", label: 'availableOffline' }
|
||||
// { workspace.flavour === 'affine-Docker', label: 'syncDocker' }
|
||||
// { workspace.flavour === 'self-hosted', label: 'selfHosted' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={style.labelWrapper}>
|
||||
{labelConditions.map(
|
||||
({ condition, label }) =>
|
||||
condition && (
|
||||
<Label
|
||||
key={label}
|
||||
value={labelMap[label].value}
|
||||
background={labelMap[label].background}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,15 @@ export const profileHandlerWrapper = style({
|
||||
marginLeft: '20px',
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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)': {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -171,7 +171,9 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (pageMeta.trash) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FlexWrapper alignItems="center" justifyContent="center">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -35,6 +35,7 @@ export const TrashButtonGroup = () => {
|
||||
<div className={group}>
|
||||
<div className={buttonContainer}>
|
||||
<Button
|
||||
data-testid="page-restore-button"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
restoreFromTrash(pageId);
|
||||
|
||||
@@ -110,9 +110,9 @@ const CloudWorkSpaceList = ({
|
||||
<>
|
||||
<StyledModalHeader>
|
||||
<StyledModalHeaderLeft>
|
||||
<StyledModalTitle>
|
||||
<StyledWorkspaceFlavourTitle>
|
||||
{t['com.affine.workspace.cloud']()}
|
||||
</StyledModalTitle>
|
||||
</StyledWorkspaceFlavourTitle>
|
||||
</StyledModalHeaderLeft>
|
||||
</StyledModalHeader>
|
||||
<StyledModalContent>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
|
||||
import { loading, speedVar } from './index.css';
|
||||
|
||||
export type LoadingProps = {
|
||||
size?: number;
|
||||
speed?: number;
|
||||
};
|
||||
|
||||
export const Loading = ({ size, speed = 1.2 }: LoadingProps) => {
|
||||
return (
|
||||
<svg
|
||||
className={loading}
|
||||
width={size ? `${size}px` : '16px'}
|
||||
height={size ? `${size}px` : '16px'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
...assignInlineVars({
|
||||
[speedVar]: `${speed}s`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM4.95017 12C4.95017 15.8935 8.10648 19.0498 12 19.0498C15.8935 19.0498 19.0498 15.8935 19.0498 12C19.0498 8.10648 15.8935 4.95017 12 4.95017C8.10648 4.95017 4.95017 8.10648 4.95017 12Z"
|
||||
fill="var(--affine-black-10)"
|
||||
/>
|
||||
<path
|
||||
d="M20.525 12C21.3396 12 22.0111 11.3361 21.8914 10.5303C21.7714 9.72269 21.5527 8.93094 21.2388 8.17317C20.7362 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C15.0691 2.44732 14.2773 2.22859 13.4697 2.10859C12.6639 1.98886 12 2.66038 12 3.475C12 4.28962 12.6674 4.93455 13.4643 5.10374C13.8853 5.19314 14.2983 5.32113 14.6979 5.48665C15.5533 5.84095 16.3304 6.36024 16.9851 7.0149C17.6398 7.66955 18.1591 8.44674 18.5133 9.30208C18.6789 9.70167 18.8069 10.1147 18.8963 10.5357C19.0655 11.3326 19.7104 12 20.525 12Z"
|
||||
fill="var(--affine-primary-color)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import { displayFlex, textEllipsis } from '@affine/component';
|
||||
import { 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)',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,7 +155,13 @@ export const RootAppSidebar = ({
|
||||
<>
|
||||
<AppSidebar
|
||||
router={router}
|
||||
hasBackground={!appSettings.enableBlurBackground}
|
||||
hasBackground={
|
||||
!(
|
||||
appSettings.enableBlurBackground &&
|
||||
environment.isDesktop &&
|
||||
environment.isMacOs
|
||||
)
|
||||
}
|
||||
>
|
||||
<SidebarContainer>
|
||||
<NoSsr>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
91
apps/core/src/hooks/use-datasource-sync.ts
Normal file
91
apps/core/src/hooks/use-datasource-sync.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import type {
|
||||
AffineSocketIOProvider,
|
||||
LocalIndexedDBBackgroundProvider,
|
||||
SQLiteProvider,
|
||||
} from '@affine/env/workspace';
|
||||
import { type Status, syncDataSource } from '@affine/y-provider';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { startTransition, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
export function useDatasourceSync(workspace: Workspace) {
|
||||
const [status, setStatus] = useState<Status>({
|
||||
type: 'idle',
|
||||
});
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const providers = workspace.providers;
|
||||
const remoteProvider: AffineSocketIOProvider | undefined = useMemo(() => {
|
||||
return providers.find(
|
||||
(provider): provider is AffineSocketIOProvider =>
|
||||
provider.flavour === 'affine-socket-io'
|
||||
);
|
||||
}, [providers]);
|
||||
const localProvider = useMemo(() => {
|
||||
const sqliteProvider = providers.find(
|
||||
(provider): provider is SQLiteProvider => provider.flavour === 'sqlite'
|
||||
);
|
||||
const indexedDbProvider = providers.find(
|
||||
(provider): provider is LocalIndexedDBBackgroundProvider =>
|
||||
provider.flavour === 'local-indexeddb-background'
|
||||
);
|
||||
const provider = sqliteProvider || indexedDbProvider;
|
||||
assertExists(provider, 'no local provider');
|
||||
return provider;
|
||||
}, [providers]);
|
||||
return [
|
||||
status,
|
||||
useCallback(() => {
|
||||
if (!remoteProvider) {
|
||||
return;
|
||||
}
|
||||
startTransition(() => {
|
||||
setStatus({
|
||||
type: 'syncing',
|
||||
});
|
||||
});
|
||||
syncDataSource(
|
||||
() => [
|
||||
workspace.doc.guid,
|
||||
...[...workspace.doc.subdocs].map(doc => doc.guid),
|
||||
],
|
||||
remoteProvider.datasource,
|
||||
localProvider.datasource
|
||||
)
|
||||
.then(async () => {
|
||||
// by default, the syncing status will show for 2.4s
|
||||
setTimeout(() => {
|
||||
startTransition(() => {
|
||||
setStatus({
|
||||
type: 'synced',
|
||||
});
|
||||
pushNotification({
|
||||
title: 'Synced successfully',
|
||||
type: 'success',
|
||||
});
|
||||
});
|
||||
}, 2400);
|
||||
})
|
||||
.catch(error => {
|
||||
startTransition(() => {
|
||||
setStatus({
|
||||
type: 'error',
|
||||
error,
|
||||
});
|
||||
pushNotification({
|
||||
title: 'Unable to Sync',
|
||||
message: 'Server error, please try again later.',
|
||||
type: 'error',
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [
|
||||
remoteProvider,
|
||||
localProvider.datasource,
|
||||
workspace.doc.guid,
|
||||
workspace.doc.subdocs,
|
||||
pushNotification,
|
||||
]),
|
||||
] as const;
|
||||
}
|
||||
@@ -33,14 +33,16 @@ import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/reac
|
||||
import { currentWorkspaceIdAtom } from '@toeverything/infra/atom';
|
||||
import { 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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
28
apps/core/src/pages/desktop-signin.tsx
Normal file
28
apps/core/src/pages/desktop-signin.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { LoaderFunction } from 'react-router-dom';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { signInCloud } from '../utils/cloud-utils';
|
||||
|
||||
const supportedProvider = z.enum(['google']);
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams;
|
||||
const provider = searchParams.get('provider');
|
||||
const callback_url = searchParams.get('callback_url');
|
||||
if (!callback_url) {
|
||||
return null;
|
||||
}
|
||||
const maybeProvider = supportedProvider.safeParse(provider);
|
||||
if (maybeProvider.success) {
|
||||
const provider = maybeProvider.data;
|
||||
await signInCloud(provider, {
|
||||
callbackUrl: callback_url,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
return null;
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export const loader: LoaderFunction = async () => {
|
||||
const target = (lastId && meta.find(({ id }) => id === lastId)) || meta.at(0);
|
||||
if (target) {
|
||||
const targetWorkspace = getWorkspace(target.id);
|
||||
|
||||
const nonTrashPages = targetWorkspace.meta.pageMetas.filter(
|
||||
({ trash }) => !trash
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
63
apps/core/src/utils/cloud-utils.tsx
Normal file
63
apps/core/src/utils/cloud-utils.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { isDesktop } from '@affine/env/constant';
|
||||
import { refreshRootMetadataAtom } from '@affine/workspace/atom';
|
||||
import { getCurrentStore } from '@toeverything/infra/atom';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { signIn, signOut } from 'next-auth/react';
|
||||
import { startTransition } from 'react';
|
||||
|
||||
export const signInCloud: typeof signIn = async (provider, ...rest) => {
|
||||
if (isDesktop) {
|
||||
if (provider === 'google') {
|
||||
open(
|
||||
`${
|
||||
runtimeConfig.serverUrlPrefix
|
||||
}/desktop-signin?provider=google&callback_url=${buildCallbackUrl(
|
||||
'/open-app/oauth-jwt'
|
||||
)}`,
|
||||
'_target'
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
const [options, ...tail] = rest;
|
||||
const callbackUrl =
|
||||
runtimeConfig.serverUrlPrefix +
|
||||
(provider === 'email' ? '/open-app/oauth-jwt' : location.pathname);
|
||||
return signIn(
|
||||
provider,
|
||||
{
|
||||
...options,
|
||||
callbackUrl: buildCallbackUrl(callbackUrl),
|
||||
},
|
||||
...tail
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return signIn(provider, ...rest);
|
||||
}
|
||||
};
|
||||
|
||||
export const signOutCloud: typeof signOut = async options => {
|
||||
return signOut({
|
||||
...options,
|
||||
callbackUrl: '/',
|
||||
}).then(result => {
|
||||
if (result) {
|
||||
startTransition(() => {
|
||||
getCurrentStore().set(refreshRootMetadataAtom);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
export function buildCallbackUrl(callbackUrl: string) {
|
||||
const params: string[][] = [];
|
||||
if (isDesktop && window.appInfo.schema) {
|
||||
params.push(['schema', window.appInfo.schema]);
|
||||
}
|
||||
const query =
|
||||
params.length > 0
|
||||
? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||
: '';
|
||||
return callbackUrl + query;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { ToastOptions } from '@affine/component';
|
||||
import { 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }>(),
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
#!/usr/bin/env ts-node-esm
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { spawn } from 'child_process';
|
||||
import { readdir } from 'fs/promises';
|
||||
import * as process from 'process';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import pkg from '../package.json' assert { type: 'json' };
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const testDir = resolve(root, 'src', 'tests');
|
||||
const files = await readdir(testDir);
|
||||
|
||||
const watchMode = process.argv.includes('--watch');
|
||||
|
||||
const sharedArgs = [
|
||||
...pkg.nodemonConfig.nodeArgs,
|
||||
'--test',
|
||||
watchMode ? '--watch' : '',
|
||||
];
|
||||
|
||||
const env = {
|
||||
PATH: process.env.PATH,
|
||||
NODE_ENV: 'test',
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_NO_WARNINGS: '1',
|
||||
};
|
||||
|
||||
if (process.argv[2] === 'all') {
|
||||
const cp = spawn('node', [...sharedArgs, resolve(testDir, '*')], {
|
||||
cwd: root,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
cp.on('exit', code => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
} else {
|
||||
const result = await p.group({
|
||||
file: () =>
|
||||
p.select({
|
||||
message: 'Select a file to run',
|
||||
options: files.map(file => ({
|
||||
label: file,
|
||||
value: file as any,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const target = resolve(testDir, result.file);
|
||||
|
||||
const cp = spawn(
|
||||
'node',
|
||||
[
|
||||
...sharedArgs,
|
||||
'--test-reporter=spec',
|
||||
'--test-reporter-destination=stdout',
|
||||
target,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
}
|
||||
);
|
||||
cp.on('exit', code => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { ConfigModule } from './config';
|
||||
import { MetricsModule } from './metrics';
|
||||
import { 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],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
38
apps/server/src/middleware/exception-logger.ts
Normal file
38
apps/server/src/middleware/exception-logger.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { REQUEST_ID } from '../constants';
|
||||
|
||||
@Catch(HttpException)
|
||||
export class ExceptionLogger implements ExceptionFilter {
|
||||
private logger = new Logger('ExceptionLogger');
|
||||
|
||||
catch(exception: Error, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request?.header(REQUEST_ID);
|
||||
this.logger.error(
|
||||
new Error(
|
||||
`${requestId ? `requestId-${requestId}:` : ''}${exception.message}`,
|
||||
{ cause: exception }
|
||||
),
|
||||
exception.stack
|
||||
);
|
||||
|
||||
const response = ctx.getResponse<Response>();
|
||||
if (exception instanceof HttpException) {
|
||||
response.json(exception.getResponse());
|
||||
} else {
|
||||
response.status(500).json({
|
||||
message: exception.message,
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { 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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
apps/server/src/modules/users/utils.ts
Normal file
3
apps/server/src/modules/users/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isStaff(email: string) {
|
||||
return email.endsWith('@toeverything.info');
|
||||
}
|
||||
@@ -48,7 +48,11 @@ export class PermissionService {
|
||||
return true;
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
60
apps/server/src/session.ts
Normal file
60
apps/server/src/session.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { Global, Injectable, Module } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import Keyv from 'keyv';
|
||||
|
||||
import { Config } from './config';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private readonly cache: Keyv;
|
||||
private readonly prefix = 'session:';
|
||||
private readonly sessionTtl = 30 * 60 * 1000; // 30 min
|
||||
|
||||
constructor(protected readonly config: Config) {
|
||||
if (config.redis.enabled) {
|
||||
this.cache = new Keyv({
|
||||
store: new KeyvRedis(
|
||||
new Redis(config.redis.port, config.redis.host, {
|
||||
username: config.redis.username,
|
||||
password: config.redis.password,
|
||||
db: config.redis.database + 2,
|
||||
})
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.cache = new Keyv();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get session
|
||||
* @param key session key
|
||||
* @returns
|
||||
*/
|
||||
async get(key: string) {
|
||||
return this.cache.get(this.prefix + key);
|
||||
}
|
||||
|
||||
/**
|
||||
* set session
|
||||
* @param key session key
|
||||
* @param value session value
|
||||
* @param sessionTtl session ttl (ms), default 30 min
|
||||
* @returns return true if success
|
||||
*/
|
||||
async set(key: string, value?: any, sessionTtl = this.sessionTtl) {
|
||||
return this.cache.set(this.prefix + key, value, sessionTtl);
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
return this.cache.delete(this.prefix + key);
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { equal, ok } from 'node:assert';
|
||||
import { 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<{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user