feat(core): impl invitation link (#11181)

feat(core): add invitee to getInviteInfoQuery

feat(core): enable invitation link

refactor(core): replace AcceptInviteService to InvitationService
This commit is contained in:
JimmFly
2025-03-26 02:45:12 +00:00
parent 64b25dfd89
commit 014556b61f
19 changed files with 409 additions and 116 deletions

View File

@@ -0,0 +1,48 @@
import {
AuthPageContainer,
type User,
} from '@affine/component/auth-components';
import type { GetInviteInfoQuery } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { Avatar } from '../../ui/avatar';
import * as styles from './styles.css';
export const FailedToSendPage = ({
user,
inviteInfo,
}: {
user: User | null;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
}) => {
const t = useI18n();
return (
<AuthPageContainer
title={t['com.affine.failed-to-send-request.title']()}
subtitle={
<div className={styles.lineHeight}>
<Trans
i18nKey="com.affine.failed-to-send-request.description"
components={{
1: (
<div className={styles.avatarWrapper}>
<Avatar
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
name={inviteInfo.workspace.name}
size={20}
colorfulFallback
/>
</div>
),
2: <span className={styles.inviteName} />,
3: <span className={styles.inviteName} />,
}}
values={{
workspaceName: inviteInfo.workspace.name,
userEmail: user?.email,
}}
/>
</div>
}
></AuthPageContainer>
);
};

View File

@@ -1,6 +1,9 @@
export * from './accept-invite-page';
export * from './expired';
export * from './failed-to-send-page';
export * from './invite-modal';
export * from './invite-team-modal';
export * from './join-failed-page';
export * from './member-limit-modal';
export * from './request-to-join-page';
export * from './sent-request-page';

View File

@@ -113,53 +113,62 @@ export const LinkInvite = ({
return (
<>
<div className={styles.modalSubTitle}>
{t['com.affine.payment.member.team.invite.link-expiration']()}
</div>
<Menu
items={items}
contentOptions={{
style: {
width: 'var(--radix-dropdown-menu-trigger-width)',
},
}}
>
<MenuTrigger style={{ width: '100%' }}>
{currentSelectedLabel}
</MenuTrigger>
</Menu>
<div className={styles.modalSubTitle}>
{t['com.affine.payment.member.team.invite.invitation-link']()}
</div>
<div className={styles.invitationLinkContent}>
<Input
value={
invitationLink
? invitationLink.link
: 'https://your-app.com/invite/xxxxxxxx'
}
inputMode="none"
disabled
inputStyle={{
fontSize: cssVar('fontXs'),
color: cssVarV2(
invitationLink ? 'text/primary' : 'text/placeholder'
),
backgroundColor: cssVarV2('layer/background/primary'),
<div className={styles.invitationLinkContainer}>
<div className={styles.modalSubTitle}>
{t['com.affine.payment.member.team.invite.link-expiration']()}
</div>
<Menu
items={items}
contentOptions={{
style: {
width: 'var(--radix-dropdown-menu-trigger-width)',
},
}}
/>
{invitationLink ? (
<>
<Button onClick={onCopy} variant="secondary">
{t['com.affine.payment.member.team.invite.copy']()}
>
<MenuTrigger style={{ width: '100%' }}>
{currentSelectedLabel}
</MenuTrigger>
</Menu>
</div>
<div className={styles.invitationLinkContainer}>
<div className={styles.modalSubTitle}>
{t['com.affine.payment.member.team.invite.invitation-link']()}
</div>
<div className={styles.invitationLinkContent}>
<Input
value={
invitationLink
? invitationLink.link
: 'https://your-app.com/invite/xxxxxxxx'
}
inputMode="none"
disabled
inputStyle={{
fontSize: cssVar('fontXs'),
color: cssVarV2(
invitationLink ? 'text/primary' : 'text/placeholder'
),
backgroundColor: cssVarV2('layer/background/primary'),
}}
/>
{invitationLink ? (
<>
<Button onClick={onCopy} variant="secondary">
{t['com.affine.payment.member.team.invite.copy']()}
</Button>
<IconButton icon={<CloseIcon />} onClick={onReset} />
</>
) : (
<Button onClick={onGenerate} variant="secondary">
{t['com.affine.payment.member.team.invite.generate']()}
</Button>
<IconButton icon={<CloseIcon />} onClick={onReset} />
</>
) : (
<Button onClick={onGenerate} variant="secondary">
{t['com.affine.payment.member.team.invite.generate']()}
</Button>
)}
)}
</div>
<p className={styles.invitationLinkDescription}>
{t[
'com.affine.payment.member.team.invite.invitation-link.description'
]()}
</p>
</div>
</>
);

View File

@@ -15,7 +15,7 @@ export const ModalContent = ({
inviteEmail,
setInviteEmail,
inviteMethod,
// onInviteMethodChange,
onInviteMethodChange,
handleConfirm,
isMutating,
isValidEmail,
@@ -48,7 +48,7 @@ export const ModalContent = ({
<RadioGroup
width={'100%'}
value={inviteMethod}
// onChange={onInviteMethodChange}
onChange={onInviteMethodChange}
items={[
{
label: (
@@ -65,13 +65,10 @@ export const ModalContent = ({
label: (
<RadioItem
icon={<LinkIcon className={styles.iconStyle} />}
label={`${t['com.affine.payment.member.team.invite.invite-link']()}(Coming soon)`}
label={t['com.affine.payment.member.team.invite.invite-link']()}
/>
),
value: 'link',
style: {
cursor: 'not-allowed',
},
},
]}
/>

View File

@@ -81,6 +81,12 @@ export const modalSubTitle = style({
fontWeight: '500',
});
export const invitationLinkContainer = style({
display: 'flex',
flexDirection: 'column',
gap: '8px',
});
export const radioItem = style({
display: 'flex',
alignItems: 'center',
@@ -107,3 +113,8 @@ export const invitationLinkContent = style({
display: 'flex',
gap: '8px',
});
export const invitationLinkDescription = style({
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontSm'),
});

View File

@@ -20,7 +20,7 @@ export const JoinFailedPage = ({
title={t['com.affine.fail-to-join-workspace.title']()}
subtitle={
userFriendlyError.name === ErrorNames.MEMBER_QUOTA_EXCEEDED ? (
<div className={styles.content}>
<div className={styles.lineHeight}>
<Trans
i18nKey={'com.affine.fail-to-join-workspace.description-1'}
components={{

View File

@@ -0,0 +1,71 @@
import {
AuthPageContainer,
type User,
} from '@affine/component/auth-components';
import type { GetInviteInfoQuery } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { SignOutIcon } from '@blocksuite/icons/rc';
import { Avatar } from '../../ui/avatar';
import { Button, IconButton } from '../../ui/button';
import * as styles from './styles.css';
export const RequestToJoinPage = ({
user,
inviteInfo,
requestToJoin,
onSignOut,
}: {
user: User | null;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
requestToJoin: () => void;
onSignOut: () => void;
}) => {
const t = useI18n();
return (
<AuthPageContainer
subtitle={
<div className={styles.content}>
<div className={styles.userWrapper}>
<Avatar
url={inviteInfo.user.avatarUrl || ''}
name={inviteInfo.user.name}
size={20}
/>
<span className={styles.inviteName}>{inviteInfo.user.name}</span>
</div>
<div>{t['invited you to join']()}</div>
<div className={styles.userWrapper}>
<Avatar
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
name={inviteInfo.workspace.name}
size={20}
style={{ marginLeft: 4 }}
colorfulFallback
/>
<span className={styles.inviteName}>
{inviteInfo.workspace.name}
</span>
</div>
</div>
}
>
<Button variant="primary" size="large" onClick={requestToJoin}>
{t['com.affine.request-to-join-workspace.button']()}
</Button>
{user ? (
<div className={styles.userInfoWrapper}>
<Avatar url={user.avatar ?? user.image} name={user.label} />
<span>{user.email}</span>
<IconButton
onClick={onSignOut}
size="20"
tooltip={t['404.signOut']()}
>
<SignOutIcon />
</IconButton>
</div>
) : null}
</AuthPageContainer>
);
};

View File

@@ -0,0 +1,48 @@
import {
AuthPageContainer,
type User,
} from '@affine/component/auth-components';
import type { GetInviteInfoQuery } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { Avatar } from '../../ui/avatar';
import * as styles from './styles.css';
export const SentRequestPage = ({
user,
inviteInfo,
}: {
user: User | null;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
}) => {
const t = useI18n();
return (
<AuthPageContainer
title={t['com.affine.sent-request-to-join-workspace.title']()}
subtitle={
<div className={styles.lineHeight}>
<Trans
i18nKey="com.affine.sent-request-to-join-workspace.description"
components={{
1: (
<div className={styles.avatarWrapper}>
<Avatar
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
name={inviteInfo.workspace.name}
size={20}
colorfulFallback
/>
</div>
),
2: <span className={styles.inviteName} />,
3: <span className={styles.inviteName} />,
}}
values={{
workspaceName: inviteInfo.workspace.name,
userEmail: user?.email,
}}
/>
</div>
}
></AuthPageContainer>
);
};

View File

@@ -15,11 +15,27 @@ export const inviteModalContent = style({
export const inviteModalButtonContainer = style({
display: 'flex',
justifyContent: 'flex-end',
// marginTop: 10,
});
export const inviteName = style({
color: cssVarV2('text/primary'),
fontWeight: '600',
});
export const avatarWrapper = style({
verticalAlign: 'sub',
display: 'inline-block',
});
export const userInfoWrapper = style({
display: 'flex',
alignItems: 'center',
gap: '12px',
marginTop: '28px',
});
export const lineHeight = style({
lineHeight: '1.5',
});
export const content = style({
@@ -30,7 +46,7 @@ export const content = style({
});
export const userWrapper = style({
display: 'flex',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
});

View File

@@ -6,7 +6,7 @@ import {
Scrollable,
Skeleton,
} from '@affine/component';
import { AcceptInviteService } from '@affine/core/modules/cloud';
import { InvitationService } from '@affine/core/modules/cloud';
import {
type Notification,
NotificationListService,
@@ -314,7 +314,7 @@ const InvitationNotificationItem = ({
const memberInactived = !body.createdByUser;
const workspaceInactived = !body.workspace;
const workspacesService = useService(WorkspacesService);
const acceptInviteService = useService(AcceptInviteService);
const invitationService = useService(InvitationService);
const notificationListService = useService(NotificationListService);
const inviteId = body.inviteId;
const [isAccepting, setIsAccepting] = useState(false);
@@ -347,8 +347,8 @@ const InvitationNotificationItem = ({
const handleAcceptInvite = useCallback(() => {
setIsAccepting(true);
acceptInviteService
.waitForAcceptInvite(inviteId)
invitationService
.acceptInvite(inviteId)
.catch(err => {
const userFriendlyError = UserFriendlyError.fromAny(err);
if (userFriendlyError.is('ALREADY_IN_SPACE')) {
@@ -384,7 +384,7 @@ const InvitationNotificationItem = ({
setIsAccepting(false);
});
}, [
acceptInviteService,
invitationService,
handleReadAndOpenWorkspace,
inviteId,
notification,

View File

@@ -1,31 +1,39 @@
import {
AcceptInvitePage,
ExpiredPage,
FailedToSendPage,
JoinFailedPage,
RequestToJoinPage,
SentRequestPage,
} from '@affine/component/member-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/error';
import { WorkspaceMemberStatus } from '@affine/graphql';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Navigate, useParams } from 'react-router-dom';
import {
RouteLogic,
useNavigateHelper,
} from '../../../components/hooks/use-navigate-helper';
import { AcceptInviteService, AuthService } from '../../../modules/cloud';
import { AuthService, InvitationService } from '../../../modules/cloud';
const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
const { jumpToPage } = useNavigateHelper();
const acceptInviteService = useService(AcceptInviteService);
const invitationService = useService(InvitationService);
const workspacesService = useService(WorkspacesService);
const error = useLiveData(acceptInviteService.error$);
const inviteId = useLiveData(acceptInviteService.inviteId$);
const inviteInfo = useLiveData(acceptInviteService.inviteInfo$);
const accepted = useLiveData(acceptInviteService.accepted$);
const loading = useLiveData(acceptInviteService.loading$);
const authService = useService(AuthService);
const user = useLiveData(authService.session.account$);
const error = useLiveData(invitationService.error$);
const inviteId = useLiveData(invitationService.inviteId$);
const inviteInfo = useLiveData(invitationService.inviteInfo$);
const loading = useLiveData(invitationService.loading$);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const navigateHelper = useNavigateHelper();
const [accepted, setAccepted] = useState(false);
const openWorkspace = useAsyncCallback(async () => {
if (!inviteInfo?.workspace.id) {
@@ -40,26 +48,40 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
}, [navigateHelper]);
useEffect(() => {
acceptInviteService.acceptInvite({
inviteId: targetInviteId,
});
}, [acceptInviteService, targetInviteId]);
useEffect(() => {
if (error && inviteId === targetInviteId) {
const err = UserFriendlyError.fromAny(error);
if (err.is('ALREADY_IN_SPACE')) {
return openWorkspace();
}
// if workspace already exists, open it
if (
!accepted &&
inviteInfo?.workspace.id &&
workspaces.some(w => w.id === inviteInfo.workspace.id)
) {
return openWorkspace();
}
}, [error, inviteId, navigateHelper, openWorkspace, targetInviteId]);
}, [accepted, inviteInfo?.workspace.id, openWorkspace, workspaces]);
const requestToJoin = useAsyncCallback(async () => {
await invitationService
.acceptInvite(targetInviteId)
.then(() => {
invitationService.getInviteInfo({ inviteId: targetInviteId });
setAccepted(true);
})
.catch(error => {
const err = UserFriendlyError.fromAny(error);
if (err.is('ALREADY_IN_SPACE')) {
return openWorkspace();
}
});
}, [invitationService, openWorkspace, targetInviteId]);
const onSignOut = useAsyncCallback(async () => {
await authService.signOut();
}, [authService]);
if (loading || inviteId !== targetInviteId) {
return null;
}
if (!inviteInfo) {
// if invite is expired
return <ExpiredPage onOpenAffine={onOpenAffine} />;
}
@@ -67,17 +89,35 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
return <JoinFailedPage inviteInfo={inviteInfo} error={error} />;
}
if (accepted) {
// for email invite
if (accepted && inviteInfo.status === WorkspaceMemberStatus.Accepted) {
return (
<AcceptInvitePage
inviteInfo={inviteInfo}
onOpenWorkspace={openWorkspace}
inviteInfo={inviteInfo}
/>
);
} else {
// invite is expired
return <ExpiredPage onOpenAffine={onOpenAffine} />;
}
if (inviteInfo.status === WorkspaceMemberStatus.UnderReview) {
return <SentRequestPage user={user} inviteInfo={inviteInfo} />;
}
if (
inviteInfo.status === WorkspaceMemberStatus.NeedMoreSeatAndReview ||
inviteInfo.status === WorkspaceMemberStatus.NeedMoreSeat
) {
return <FailedToSendPage user={user} inviteInfo={inviteInfo} />;
}
return (
<RequestToJoinPage
user={user}
inviteInfo={inviteInfo}
requestToJoin={requestToJoin}
onSignOut={onSignOut}
/>
);
};
/**
@@ -87,13 +127,17 @@ const AcceptInvite = ({ inviteId: targetInviteId }: { inviteId: string }) => {
*/
export const Component = () => {
const authService = useService(AuthService);
const invitationService = useService(InvitationService);
const isRevalidating = useLiveData(authService.session.isRevalidating$);
const loginStatus = useLiveData(authService.session.status$);
const params = useParams<{ inviteId: string }>();
useEffect(() => {
authService.session.revalidate();
}, [authService]);
if (params.inviteId) {
invitationService.getInviteInfo({ inviteId: params.inviteId });
}
}, [authService, invitationService, params.inviteId]);
const { jumpToSignIn } = useNavigateHelper();

View File

@@ -7,13 +7,13 @@ export { AccountLoggedOut } from './events/account-logged-out';
export { AuthProvider } from './provider/auth';
export { ValidatorProvider } from './provider/validator';
export { ServerScope } from './scopes/server';
export { AcceptInviteService } from './services/accept-invite';
export { AuthService } from './services/auth';
export { CaptchaService } from './services/captcha';
export { DefaultServerService } from './services/default-server';
export { EventSourceService } from './services/eventsource';
export { FetchService } from './services/fetch';
export { GraphQLService } from './services/graphql';
export { InvitationService } from './services/invitation';
export { InvoicesService } from './services/invoices';
export { PublicUserService } from './services/public-user';
export { SelfhostGenerateLicenseService } from './services/selfhost-generate-license';
@@ -33,6 +33,7 @@ export { WorkspaceServerService } from './services/workspace-server';
export { WorkspaceSubscriptionService } from './services/workspace-subscription';
export type { ServerConfig } from './types';
// eslint-disable-next-line simple-import-sort/imports
import { type Framework } from '@toeverything/infra';
import { DocScope } from '../doc/scopes/doc';
@@ -56,7 +57,7 @@ import { configureDefaultAuthProvider } from './impl/auth';
import { AuthProvider } from './provider/auth';
import { ValidatorProvider } from './provider/validator';
import { ServerScope } from './scopes/server';
import { AcceptInviteService } from './services/accept-invite';
import { InvitationService } from './services/invitation';
import { AuthService } from './services/auth';
import { BlocksuiteWriterInfoService } from './services/blocksuite-writer-info';
import { CaptchaService } from './services/captcha';
@@ -153,7 +154,7 @@ export function configureCloudModule(framework: Framework) {
.service(SelfhostGenerateLicenseService, [SelfhostGenerateLicenseStore])
.store(SelfhostGenerateLicenseStore, [GraphQLService])
.store(InviteInfoStore, [GraphQLService])
.service(AcceptInviteService, [AcceptInviteStore, InviteInfoStore])
.service(InvitationService, [AcceptInviteStore, InviteInfoStore])
.store(AcceptInviteStore, [GraphQLService])
.service(PublicUserService, [PublicUserStore])
.store(PublicUserStore, [GraphQLService])

View File

@@ -16,20 +16,19 @@ import type { InviteInfoStore } from '../stores/invite-info';
export type InviteInfo = GetInviteInfoQuery['getInviteInfo'];
export class AcceptInviteService extends Service {
export class InvitationService extends Service {
constructor(
private readonly store: AcceptInviteStore,
private readonly acceptInviteStore: AcceptInviteStore,
private readonly inviteInfoStore: InviteInfoStore
) {
super();
}
inviteId$ = new LiveData<string | undefined>(undefined);
inviteInfo$ = new LiveData<InviteInfo | undefined>(undefined);
accepted$ = new LiveData<boolean>(false);
loading$ = new LiveData(false);
error$ = new LiveData<any>(null);
readonly acceptInvite = effect(
readonly getInviteInfo = effect(
switchMap(({ inviteId }: { inviteId: string }) => {
if (!inviteId) {
return EMPTY;
@@ -39,16 +38,6 @@ export class AcceptInviteService extends Service {
}).pipe(
mergeMap(res => {
this.inviteInfo$.setValue(res);
return fromPromise(async () => {
return await this.store.acceptInvite(
res.workspace.id,
inviteId,
true
);
});
}),
mergeMap(res => {
this.accepted$.next(res);
return EMPTY;
}),
smartRetry({
@@ -59,7 +48,6 @@ export class AcceptInviteService extends Service {
this.inviteId$.setValue(inviteId);
this.loading$.setValue(true);
this.inviteInfo$.setValue(undefined);
this.accepted$.setValue(false);
}),
onComplete(() => {
this.loading$.setValue(false);
@@ -68,21 +56,20 @@ export class AcceptInviteService extends Service {
})
);
async waitForAcceptInvite(inviteId: string) {
this.acceptInvite({ inviteId });
async acceptInvite(inviteId: string) {
this.getInviteInfo({ inviteId });
await this.loading$.waitFor(f => !f);
if (this.accepted$.value) {
return true; // invite is accepted
if (!this.inviteInfo$.value) {
throw new Error('Invalid invite id');
}
if (this.error$.value) {
throw this.error$.value;
}
return false; // invite is expired
return await this.acceptInviteStore.acceptInvite(
this.inviteInfo$.value.workspace.id,
inviteId,
true
);
}
override dispose(): void {
this.acceptInvite.unsubscribe();
this.getInviteInfo.unsubscribe();
}
}

View File

@@ -6,7 +6,7 @@
"el-GR": 94,
"en": 100,
"es-AR": 94,
"es-CL": 96,
"es-CL": 95,
"es": 94,
"fa": 94,
"fr": 94,

View File

@@ -7203,6 +7203,18 @@ export function useAFFiNEI18N(): {
* `Please contact your workspace owner to add more seats.`
*/
["com.affine.fail-to-join-workspace.description-2"](): string;
/**
* `Request to join`
*/
["com.affine.request-to-join-workspace.button"](): string;
/**
* `Request Sent successfully`
*/
["com.affine.sent-request-to-join-workspace.title"](): string;
/**
* `Request failed to send`
*/
["com.affine.failed-to-send-request.title"](): string;
/**
* `Readwise`
*/
@@ -8445,6 +8457,28 @@ export const TypedTrans: {
["1"]: JSX.Element;
["2"]: JSX.Element;
}>>;
/**
* `You requested to join <1/> <2>{{workspaceName}}</2> with <3>{{userEmail}}</3>, the workspace owner and team admins will review your request.`
*/
["com.affine.sent-request-to-join-workspace.description"]: ComponentType<TypedTransProps<Readonly<{
workspaceName: string;
userEmail: string;
}>, {
["1"]: JSX.Element;
["2"]: JSX.Element;
["3"]: JSX.Element;
}>>;
/**
* `Unable to process your request to join <1/> <2>{{workspaceName}}</2> with <3>{{userEmail}}</3>, the workspace has reached its member limit. Please contact the workspace owner for available seats.`
*/
["com.affine.failed-to-send-request.description"]: ComponentType<TypedTransProps<Readonly<{
workspaceName: string;
userEmail: string;
}>, {
["1"]: JSX.Element;
["2"]: JSX.Element;
["3"]: JSX.Element;
}>>;
/**
* `Import your Readwise highlights to AFFiNE. Please visit Readwise, click <a>"Get Access Token"</a>, and paste the token below.`
*/

View File

@@ -1792,6 +1792,11 @@
"com.affine.fail-to-join-workspace.title": "Join Failed",
"com.affine.fail-to-join-workspace.description-1": "Unable to join <1/> <2>{{workspaceName}}</2> due to insufficient seats available.",
"com.affine.fail-to-join-workspace.description-2": "Please contact your workspace owner to add more seats.",
"com.affine.request-to-join-workspace.button": "Request to join",
"com.affine.sent-request-to-join-workspace.title": "Request Sent successfully",
"com.affine.sent-request-to-join-workspace.description": "You requested to join <1/> <2>{{workspaceName}}</2> with <3>{{userEmail}}</3>, the workspace owner and team admins will review your request.",
"com.affine.failed-to-send-request.title": "Request failed to send",
"com.affine.failed-to-send-request.description": "Unable to process your request to join <1/> <2>{{workspaceName}}</2> with <3>{{userEmail}}</3>, the workspace has reached its member limit. Please contact the workspace owner for available seats.",
"com.affine.integration.name.readwise": "Readwise",
"com.affine.integration.integrations": "Integrations",
"com.affine.integration.setting.description": "Elevate your AFFiNE experience with diverse add-ons and seamless integrations.",