mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(core): mcp server setting (#13630)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * MCP Server integration available in cloud workspaces with a dedicated settings panel. * Manage personal access tokens: generate/revoke tokens and view revealed token. * One-click copy of a prefilled server configuration JSON. * New query to fetch revealed access tokens. * **Improvements** * Integration list adapts to workspace type (cloud vs. local). * More reliable token refresh with clearer loading, error and revalidation states. * **Localization** * Added “Copied to clipboard” message and MCP Server name/description translations. * **Chores** * Updated icon dependency across many packages. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { IntegrationTypeIcon } from '@affine/core/modules/integration';
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
import { Logo1Icon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { LiveData } from '@toeverything/infra';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { CalendarSettingPanel } from './calendar/setting-panel';
|
||||
import MCPIcon from './mcp-server/MCP.inline.svg';
|
||||
import { McpServerSettingPanel } from './mcp-server/setting-panel';
|
||||
import { ReadwiseSettingPanel } from './readwise/setting-panel';
|
||||
|
||||
type IntegrationCard = {
|
||||
@@ -13,6 +13,7 @@ type IntegrationCard = {
|
||||
name: I18nString;
|
||||
desc: I18nString;
|
||||
icon: ReactNode;
|
||||
cloud?: boolean;
|
||||
} & (
|
||||
| {
|
||||
setting: ReactNode;
|
||||
@@ -37,6 +38,14 @@ const INTEGRATION_LIST = [
|
||||
icon: <TodayIcon />,
|
||||
setting: <CalendarSettingPanel />,
|
||||
},
|
||||
{
|
||||
id: 'mcp-server' as const,
|
||||
name: 'com.affine.integration.mcp-server.name',
|
||||
desc: 'com.affine.integration.mcp-server.desc',
|
||||
icon: <img src={MCPIcon} />,
|
||||
setting: <McpServerSettingPanel />,
|
||||
cloud: true,
|
||||
},
|
||||
{
|
||||
id: 'web-clipper' as const,
|
||||
name: 'com.affine.integration.web-clipper.name',
|
||||
@@ -55,13 +64,11 @@ export type IntegrationItem = Exclude<IntegrationCard, 'id'> & {
|
||||
id: IntegrationId;
|
||||
};
|
||||
|
||||
export function getAllowedIntegrationList$(
|
||||
_featureFlagService: FeatureFlagService
|
||||
) {
|
||||
return LiveData.computed(() => {
|
||||
return INTEGRATION_LIST.filter(item => {
|
||||
if (!item) return false;
|
||||
return true;
|
||||
}) as IntegrationItem[];
|
||||
});
|
||||
export function getAllowedIntegrationList(isCloudWorkspace: boolean) {
|
||||
return INTEGRATION_LIST.filter(item => {
|
||||
if (!item) return false;
|
||||
const requiredCloud = 'cloud' in item && item.cloud;
|
||||
if (requiredCloud && !isCloudWorkspace) return false;
|
||||
return true;
|
||||
}) as IntegrationItem[];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { SubPageProvider, useSubPageIsland } from '../../sub-page';
|
||||
@@ -10,22 +10,21 @@ import {
|
||||
IntegrationCardContent,
|
||||
IntegrationCardHeader,
|
||||
} from './card';
|
||||
import { getAllowedIntegrationList$ } from './constants';
|
||||
import { getAllowedIntegrationList } from './constants';
|
||||
import { type IntegrationItem } from './constants';
|
||||
import { list } from './index.css';
|
||||
|
||||
export const IntegrationSetting = () => {
|
||||
const t = useI18n();
|
||||
const [opened, setOpened] = useState<string | null>(null);
|
||||
const featureFlagService = useService(FeatureFlagService);
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const isCloudWorkspace = workspaceService.workspace.flavour !== 'local';
|
||||
console.log('isCloudWorkspace', isCloudWorkspace);
|
||||
|
||||
const integrationList = useLiveData(
|
||||
useMemo(
|
||||
() => getAllowedIntegrationList$(featureFlagService),
|
||||
[featureFlagService]
|
||||
)
|
||||
const integrationList = useMemo(
|
||||
() => getAllowedIntegrationList(isCloudWorkspace),
|
||||
[isCloudWorkspace]
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback((card: IntegrationItem) => {
|
||||
if ('setting' in card && card.setting) {
|
||||
setOpened(card.id);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5176 2.33008C14.4229 2.3301 15.2926 2.68402 15.9414 3.31543C16.3167 3.6805 16.6 4.1293 16.7686 4.625C16.9372 5.12079 16.9861 5.64968 16.9111 6.16797C17.4363 6.09327 17.9724 6.13983 18.4766 6.30469C18.9805 6.46951 19.4399 6.74786 19.8193 7.11816L19.8604 7.15918C20.1778 7.46798 20.4302 7.8372 20.6025 8.24512C20.775 8.65341 20.8643 9.09291 20.8643 9.53613C20.8642 9.97924 20.775 10.418 20.6025 10.8262C20.4301 11.2343 20.178 11.6041 19.8604 11.9131L12.8447 18.792C12.8236 18.8126 12.8064 18.8371 12.7949 18.8643C12.7834 18.8914 12.7774 18.9207 12.7773 18.9502C12.7773 18.9796 12.7835 19.009 12.7949 19.0361C12.8064 19.0633 12.8236 19.0878 12.8447 19.1084L14.2852 20.5225C14.3486 20.5842 14.3991 20.6577 14.4336 20.7393C14.468 20.8208 14.4863 20.9086 14.4863 20.9971C14.4863 21.0857 14.4681 21.1742 14.4336 21.2559C14.3991 21.3374 14.3486 21.411 14.2852 21.4727C14.1554 21.5988 13.9818 21.6699 13.8008 21.6699C13.6198 21.6699 13.4462 21.5988 13.3164 21.4727L11.875 20.0605C11.7266 19.9164 11.6089 19.7433 11.5283 19.5527C11.4479 19.3624 11.4063 19.1578 11.4062 18.9512C11.4062 18.7443 11.4478 18.5392 11.5283 18.3486C11.6089 18.158 11.7266 17.985 11.875 17.8408L18.8906 10.9609C19.0811 10.7755 19.2326 10.5535 19.3359 10.3086C19.4392 10.0638 19.4922 9.80086 19.4922 9.53516C19.4922 9.26926 19.4394 9.00571 19.3359 8.76074C19.2325 8.51584 19.0811 8.29382 18.8906 8.1084L18.8506 8.06934C18.4617 7.69082 17.9402 7.47897 17.3975 7.47852C16.8548 7.47807 16.3329 7.68856 15.9434 8.06641L10.165 13.7344L10.1631 13.7363L10.084 13.8145C9.95415 13.9409 9.77984 14.0117 9.59863 14.0117C9.4176 14.0116 9.24401 13.9407 9.11426 13.8145C9.05077 13.7527 8.99933 13.6792 8.96484 13.5977C8.93035 13.516 8.91309 13.4275 8.91309 13.3389C8.91313 13.2504 8.93041 13.1626 8.96484 13.0811C8.99934 12.9994 9.05071 12.9251 9.11426 12.8633L14.9746 7.11621C15.1646 6.93067 15.315 6.70863 15.418 6.46387C15.521 6.21904 15.5744 5.95604 15.5742 5.69043C15.574 5.42482 15.5204 5.16164 15.417 4.91699C15.3136 4.67244 15.1619 4.45082 14.9717 4.26562C14.5824 3.8869 14.0607 3.67483 13.5176 3.6748C12.9745 3.6748 12.4528 3.88693 12.0635 4.26562L4.30664 11.873C4.17685 11.9991 4.00225 12.0693 3.82129 12.0693C3.6405 12.0692 3.46659 11.999 3.33691 11.873C3.27337 11.8112 3.22297 11.7369 3.18848 11.6553C3.15402 11.5737 3.13578 11.486 3.13574 11.3975C3.13574 11.309 3.1541 11.2212 3.18848 11.1396C3.22295 11.058 3.27342 10.9837 3.33691 10.9219L11.0938 3.31543C11.7426 2.684 12.6122 2.33008 13.5176 2.33008ZM13.3311 5.01953C13.512 5.01964 13.6857 5.09058 13.8154 5.2168C13.879 5.2786 13.9294 5.35291 13.9639 5.43457C13.9983 5.51617 14.0166 5.60381 14.0166 5.69238C14.0166 5.78097 13.9983 5.86859 13.9639 5.9502C13.9294 6.03182 13.879 6.10619 13.8154 6.16797L8.07812 11.7939C7.88771 11.9794 7.73618 12.2014 7.63281 12.4463C7.52954 12.6911 7.47659 12.9541 7.47656 13.2197C7.47656 13.4856 7.5294 13.7492 7.63281 13.9941C7.73622 14.2391 7.8876 14.461 8.07812 14.6465C8.46746 15.0254 8.98992 15.2373 9.5332 15.2373C10.0763 15.2372 10.5981 15.0252 10.9873 14.6465L16.7236 9.02051C16.8535 8.89411 17.0278 8.82324 17.209 8.82324C17.3902 8.82326 17.5645 8.89412 17.6943 9.02051C17.7577 9.08221 17.8083 9.15584 17.8428 9.2373C17.8772 9.31882 17.8945 9.40663 17.8945 9.49512C17.8945 9.58376 17.8773 9.67225 17.8428 9.75391C17.8083 9.83536 17.7577 9.90901 17.6943 9.9707L11.957 15.5967C11.3082 16.2278 10.4384 16.581 9.5332 16.5811C8.62789 16.5811 7.75831 16.2279 7.10938 15.5967C6.79165 15.2877 6.53867 14.918 6.36621 14.5098C6.19386 14.1016 6.10547 13.6628 6.10547 13.2197C6.1055 12.7766 6.19378 12.3379 6.36621 11.9297C6.53867 11.5214 6.79164 11.1518 7.10938 10.8428L12.8457 5.2168C12.9755 5.09044 13.1499 5.01953 13.3311 5.01953Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,48 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const connectButton = style({
|
||||
width: '100%',
|
||||
marginTop: '24px',
|
||||
});
|
||||
|
||||
export const section = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: `1px solid ${cssVar('borderColor')}`,
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
gap: '0px',
|
||||
marginBottom: '16px',
|
||||
});
|
||||
|
||||
export const sectionHeader = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const preArea = style({
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
padding: '16px 16px',
|
||||
borderRadius: '8px',
|
||||
margin: '8px 0',
|
||||
fontFamily: cssVar('fontMonoFamily'),
|
||||
overflowX: 'auto',
|
||||
});
|
||||
|
||||
export const sectionDescription = style({
|
||||
fontSize: 13,
|
||||
lineHeight: '22px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const sectionTitle = style({
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.25,
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Button, ErrorMessage, notify, Skeleton } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { AccessTokenService, ServerService } from '@affine/core/modules/cloud';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { IntegrationSettingHeader } from '../setting';
|
||||
import MCPIcon from './MCP.inline.svg';
|
||||
import * as styles from './setting-panel.css';
|
||||
|
||||
export const McpServerSettingPanel = () => {
|
||||
return <McpServerSetting />;
|
||||
};
|
||||
|
||||
const McpServerSettingHeader = ({ action }: { action?: ReactNode }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<IntegrationSettingHeader
|
||||
icon={<img src={MCPIcon} />}
|
||||
name={t['com.affine.integration.mcp-server.name']()}
|
||||
desc={t['com.affine.integration.mcp-server.desc']()}
|
||||
action={action}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const McpServerSetting = () => {
|
||||
const workspaceService = useService(WorkspaceService);
|
||||
const serverService = useService(ServerService);
|
||||
const workspaceName = useLiveData(workspaceService.workspace.name$);
|
||||
const accessTokenService = useService(AccessTokenService);
|
||||
const accessTokens = useLiveData(accessTokenService.accessTokens$);
|
||||
const isRevalidating = useLiveData(accessTokenService.isRevalidating$);
|
||||
const error = useLiveData(accessTokenService.error$);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const t = useI18n();
|
||||
|
||||
const mcpAccessToken = useMemo(() => {
|
||||
return accessTokens?.find(token => token.name === 'mcp');
|
||||
}, [accessTokens]);
|
||||
|
||||
const code = useMemo(() => {
|
||||
return mcpAccessToken
|
||||
? JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[`${workspaceName} - AFFiNE`]: {
|
||||
type: 'streamable-http',
|
||||
url: `${serverService.server.baseUrl}/api/workspaces/${workspaceService.workspace.id}/mcp`,
|
||||
note: 'Read docs from AFFiNE workspace',
|
||||
headers: {
|
||||
Authorization: `Bearer ${mcpAccessToken.token}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
: null;
|
||||
}, [mcpAccessToken, workspaceName, workspaceService, serverService]);
|
||||
|
||||
const showLoading = accessTokens === null && isRevalidating;
|
||||
const showError = accessTokens === null && error !== null;
|
||||
|
||||
useEffect(() => {
|
||||
accessTokenService.revalidate();
|
||||
}, [accessTokenService]);
|
||||
|
||||
const handleGenerateAccessToken = useAsyncCallback(async () => {
|
||||
setMutating(true);
|
||||
try {
|
||||
if (mcpAccessToken) {
|
||||
await accessTokenService.revokeUserAccessToken(mcpAccessToken.id);
|
||||
}
|
||||
await accessTokenService.generateUserAccessToken('mcp');
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
error: UserFriendlyError.fromAny(err),
|
||||
});
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
}, [accessTokenService, mcpAccessToken]);
|
||||
|
||||
const handleRevokeAccessToken = useAsyncCallback(async () => {
|
||||
setMutating(true);
|
||||
try {
|
||||
if (mcpAccessToken) {
|
||||
await accessTokenService.revokeUserAccessToken(mcpAccessToken.id);
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
error: UserFriendlyError.fromAny(err),
|
||||
});
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
}, [accessTokenService, mcpAccessToken]);
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<div>
|
||||
<McpServerSettingHeader />
|
||||
<Skeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showError) {
|
||||
return (
|
||||
<div>
|
||||
<McpServerSettingHeader />
|
||||
<ErrorMessage>{error}</ErrorMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<McpServerSettingHeader />
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>Personal access token</div>
|
||||
{!mcpAccessToken ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerateAccessToken}
|
||||
disabled={mutating}
|
||||
>
|
||||
Create New
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="error"
|
||||
onClick={handleRevokeAccessToken}
|
||||
disabled={mutating}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.sectionDescription}>
|
||||
This access token is used for the MCP service, please keep this
|
||||
information secure. Deleting it will invalidate the access token.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>Server Config</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code);
|
||||
notify.success({
|
||||
title: t['Copied to clipboard'](),
|
||||
});
|
||||
}}
|
||||
disabled={!code || mutating}
|
||||
>
|
||||
Copy json
|
||||
</Button>
|
||||
</div>
|
||||
{code ? (
|
||||
<pre className={styles.preArea}>{code}</pre>
|
||||
) : (
|
||||
<p
|
||||
className={styles.sectionDescription}
|
||||
style={{ textAlign: 'center' }}
|
||||
>
|
||||
No access token found, please generate one first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>Support tools</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>doc-read</div>
|
||||
</div>
|
||||
<div className={styles.sectionDescription}>
|
||||
Return the complete text and basic metadata of a single document
|
||||
identified by docId; use this when the user needs the full content
|
||||
of a specific file rather than a search result.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>doc-semantic-search</div>
|
||||
</div>
|
||||
<div className={styles.sectionDescription}>
|
||||
Retrieve conceptually related passages by performing vector-based
|
||||
semantic similarity search across embedded documents; use this tool
|
||||
only when exact keyword search fails or the user explicitly needs
|
||||
meaning-level matches (e.g., paraphrases, synonyms, broader
|
||||
concepts, recent documents).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionTitle}>doc-keyword-search</div>
|
||||
</div>
|
||||
<div className={styles.sectionDescription}>
|
||||
Fuzzy search all workspace documents for the exact keyword or phrase
|
||||
supplied and return passages ranked by textual match. Use this tool
|
||||
by default whenever a straightforward term-based or keyword-base
|
||||
lookup is sufficient.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ export { AccountLoggedOut } from './events/account-logged-out';
|
||||
export { AuthProvider } from './provider/auth';
|
||||
export { ValidatorProvider } from './provider/validator';
|
||||
export { ServerScope } from './scopes/server';
|
||||
export { AccessTokenService } from './services/access-token';
|
||||
export { AuthService } from './services/auth';
|
||||
export { CaptchaService } from './services/captcha';
|
||||
export { DefaultServerService } from './services/default-server';
|
||||
@@ -102,6 +103,8 @@ import { WorkspacePermissionService } from '../permissions';
|
||||
import { DocScope, DocService, DocsService } from '../doc';
|
||||
import { DocCreatedByUpdatedBySyncStore } from './stores/doc-created-by-updated-by-sync';
|
||||
import { GlobalDialogService } from '../dialogs';
|
||||
import { AccessTokenService } from './services/access-token';
|
||||
import { AccessTokenStore } from './stores/access-token';
|
||||
|
||||
export function configureCloudModule(framework: Framework) {
|
||||
configureDefaultAuthProvider(framework);
|
||||
@@ -171,7 +174,9 @@ export function configureCloudModule(framework: Framework) {
|
||||
.service(PublicUserService, [PublicUserStore])
|
||||
.store(PublicUserStore, [GraphQLService])
|
||||
.service(UserSettingsService, [UserSettingsStore])
|
||||
.store(UserSettingsStore, [GraphQLService]);
|
||||
.store(UserSettingsStore, [GraphQLService])
|
||||
.service(AccessTokenService, [AccessTokenStore])
|
||||
.store(AccessTokenStore, [GraphQLService]);
|
||||
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
effect,
|
||||
exhaustMapWithTrailing,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
OnEvent,
|
||||
onStart,
|
||||
Service,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import { catchError, EMPTY, tap } from 'rxjs';
|
||||
|
||||
import { AccountChanged } from '../events/account-changed';
|
||||
import type { AccessToken, AccessTokenStore } from '../stores/access-token';
|
||||
|
||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||
export class AccessTokenService extends Service {
|
||||
constructor(private readonly accessTokenStore: AccessTokenStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
accessTokens$ = new LiveData<AccessToken[] | null>(null);
|
||||
isRevalidating$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
async generateUserAccessToken(name: string) {
|
||||
const accessToken =
|
||||
await this.accessTokenStore.generateUserAccessToken(name);
|
||||
this.accessTokens$.value = [
|
||||
...(this.accessTokens$.value || []),
|
||||
accessToken as AccessToken,
|
||||
];
|
||||
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
async revokeUserAccessToken(id: string) {
|
||||
await this.accessTokenStore.revokeUserAccessToken(id);
|
||||
this.accessTokens$.value =
|
||||
this.accessTokens$.value?.filter(token => token.id !== id) ?? null;
|
||||
await this.waitForRevalidation();
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
exhaustMapWithTrailing(() => {
|
||||
return fromPromise(() => {
|
||||
return this.accessTokenStore.listUserAccessTokens();
|
||||
}).pipe(
|
||||
smartRetry(),
|
||||
tap(accessTokens => {
|
||||
this.accessTokens$.value = accessTokens;
|
||||
}),
|
||||
catchError(error => {
|
||||
this.error$.value = error;
|
||||
return EMPTY;
|
||||
}),
|
||||
onStart(() => {
|
||||
this.isRevalidating$.value = true;
|
||||
}),
|
||||
onComplete(() => {
|
||||
this.isRevalidating$.value = false;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
private onAccountChanged() {
|
||||
this.accessTokens$.value = null;
|
||||
this.revalidate();
|
||||
}
|
||||
|
||||
async waitForRevalidation(signal?: AbortSignal) {
|
||||
this.revalidate();
|
||||
await this.isRevalidating$.waitFor(
|
||||
isRevalidating => !isRevalidating,
|
||||
signal
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
generateUserAccessTokenMutation,
|
||||
type ListUserAccessTokensQuery,
|
||||
listUserAccessTokensQuery,
|
||||
revokeUserAccessTokenMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { GraphQLService } from '../services/graphql';
|
||||
|
||||
export type AccessToken =
|
||||
ListUserAccessTokensQuery['revealedAccessTokens'][number];
|
||||
|
||||
export class AccessTokenStore extends Store {
|
||||
constructor(private readonly gqlService: GraphQLService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async listUserAccessTokens(signal?: AbortSignal): Promise<AccessToken[]> {
|
||||
const data = await this.gqlService.gql({
|
||||
query: listUserAccessTokensQuery,
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return data.revealedAccessTokens;
|
||||
}
|
||||
|
||||
async generateUserAccessToken(
|
||||
name: string,
|
||||
expiresAt?: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: generateUserAccessTokenMutation,
|
||||
variables: { input: { name, expiresAt } },
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return data.generateUserAccessToken;
|
||||
}
|
||||
|
||||
async revokeUserAccessToken(id: string, signal?: AbortSignal) {
|
||||
const data = await this.gqlService.gql({
|
||||
query: revokeUserAccessTokenMutation,
|
||||
variables: { id },
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return data.revokeUserAccessToken;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user