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',
});