Skip to content

Commit

Permalink
feat(core): impl team workspace (#8920)
Browse files Browse the repository at this point in the history
AF-1738 AF-1735 AF-1731 AF-1721 AF-1717 AF-1736 AF-1727 AF-1719 AF-1877
UI for team workspaces :
- add upgrade to team & successful upgrade page ( `/upgrade-to-team` & `/upgrade-success/team`)
- update team plans on pricing page ( settings —> pricing plans )
- update reaching the usage/member limit modal
- update invite member modal
- update member CRUD options
  • Loading branch information
JimmFly committed Dec 10, 2024
1 parent 5d25580 commit 612310b
Show file tree
Hide file tree
Showing 77 changed files with 3,788 additions and 1,044 deletions.
15 changes: 15 additions & 0 deletions packages/backend/server/src/core/workspaces/resolvers/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,21 @@ export class WorkspaceResolver {
return data?.user?.id === user.id;
}

@Query(() => Boolean, {
description: 'Get is admin of workspace',
complexity: 2,
})
async isAdmin(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
return this.permissions.tryCheckWorkspaceIs(
workspaceId,
user.id,
Permission.Admin
);
}

@Query(() => [WorkspaceType], {
description: 'Get all accessible workspaces for current user',
complexity: 2,
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/server/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,9 @@ type Query {
"""send workspace invitation"""
getInviteInfo(inviteId: String!): InvitationType!

"""Get is admin of workspace"""
isAdmin(workspaceId: String!): Boolean!

"""Get is owner of workspace"""
isOwner(workspaceId: String!): Boolean!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface WorkspaceProfileInfo {
avatar?: string;
name?: string;
isOwner?: boolean;
isAdmin?: boolean;
isTeam?: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ export class WorkspaceProfileCacheStore extends Store {
}

const info = data as WorkspaceProfileInfo;

return {
avatar: info.avatar,
name: info.name,
isOwner: info.isOwner,
isAdmin: info.isAdmin,
isTeam: info.isTeam,
};
})
);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export const root = style({
flexDirection: 'column',
fontSize: cssVar('fontBase'),
position: 'relative',
background: cssVar('backgroundPrimaryColor'),
backgroundColor: cssVar('backgroundPrimaryColor'),
backgroundSize: 'cover',
});
export const affineLogo = style({
color: 'inherit',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Button } from '@affine/component/ui/button';
import { useI18n } from '@affine/i18n';
import { Logo1Icon } from '@blocksuite/icons/rc';
import { useTheme } from 'next-themes';
import { type ReactNode, useCallback } from 'react';

import dotBgDark from './assets/dot-bg.dark.png';
import dotBgLight from './assets/dot-bg.light.png';
import { DesktopNavbar } from './desktop-navbar';
import * as styles from './index.css';
import { MobileNavbar } from './mobile-navbar';
Expand All @@ -18,8 +21,15 @@ export const AffineOtherPageLayout = ({
open(BUILD_CONFIG.downloadUrl, '_blank');
}, []);

const { resolvedTheme } = useTheme();
const backgroundImage =
resolvedTheme === 'dark' && dotBgDark ? dotBgDark : dotBgLight;

return (
<div className={styles.root}>
<div
className={styles.root}
style={{ backgroundImage: `url(${backgroundImage})` }}
>
{BUILD_CONFIG.isElectron ? (
<div className={styles.draggableHeader} />
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { FC, PropsWithChildren, ReactNode } from 'react';

import { Empty } from '../../ui/empty';
import { ThemedImg } from '../../ui/themed-img';
import { AffineOtherPageLayout } from '../affine-other-page-layout';
import { authPageContainer, hideInSmallScreen } from './share.css';
import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png';
import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png';
import {
authPageContainer,
hideInSmallScreen,
illustration,
} from './share.css';

export const AuthPageContainer: FC<
PropsWithChildren<{
Expand All @@ -20,7 +26,12 @@ export const AuthPageContainer: FC<
{children}
</div>
<div className={hideInSmallScreen}>
<Empty />
<ThemedImg
draggable={false}
className={illustration}
lightSrc={illustrationLight}
darkSrc={illustrationDark}
/>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,33 @@ export const authPageContainer = style({
globalStyle(`${authPageContainer} .wrapper`, {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'center',
overflow: 'hidden',
'@media': {
'screen and (max-width: 1024px)': {
flexDirection: 'column',
justifyContent: 'flex-start',
},
},
});
globalStyle(`${authPageContainer} .content`, {
maxWidth: '700px',
maxWidth: '810px',
'@media': {
'screen and (min-width: 1024px)': {
marginLeft: '200px',
minWidth: '500px',
marginRight: '60px',
flexGrow: 1,
flexShrink: 0,
flexBasis: 0,
},
'screen and (max-width: 1024px)': {
maxWidth: '600px',
width: '100%',
margin: 'auto',
},
},
});
globalStyle(`${authPageContainer} .title`, {
fontSize: cssVar('fontTitle'),
Expand Down Expand Up @@ -203,3 +222,8 @@ export const hideInSmallScreen = style({
},
},
});

export const illustration = style({
flexShrink: 0,
width: '670px',
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useI18n } from '@affine/i18n';

import { Avatar } from '../../ui/avatar';
import { Button } from '../../ui/button';
import { FlexWrapper } from '../../ui/layout';
import * as styles from './styles.css';
export const AcceptInvitePage = ({
onOpenWorkspace,
Expand All @@ -18,23 +17,29 @@ export const AcceptInvitePage = ({
<AuthPageContainer
title={t['Successfully joined!']()}
subtitle={
<FlexWrapper alignItems="center">
<Avatar
url={inviteInfo.user.avatarUrl || ''}
name={inviteInfo.user.name}
size={20}
/>
<span className={styles.inviteName}>{inviteInfo.user.name}</span>
{t['invited you to join']()}
<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>
</FlexWrapper>
<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={onOpenWorkspace}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './accept-invite-page';
export * from './invite-modal';
export * from './invite-team-modal';
export * from './member-limit-modal';
export * from './pagination';
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useI18n } from '@affine/i18n';
import { cssVar } from '@toeverything/theme';

import Input from '../../../ui/input';
import * as styles from './styles.css';

export const EmailInvite = ({
inviteEmail,
setInviteEmail,
handleConfirm,
importCSV,
isMutating,
isValidEmail,
}: {
inviteEmail: string;
setInviteEmail: (value: string) => void;
handleConfirm: () => void;
isMutating: boolean;
isValidEmail: boolean;
importCSV: React.ReactNode;
}) => {
const t = useI18n();
return (
<>
<div className={styles.modalSubTitle}>
{t['com.affine.payment.member.team.invite.email-invite']()}
</div>
<div>
<Input
inputStyle={{ fontSize: cssVar('fontXs') }}
disabled={isMutating}
placeholder={t[
'com.affine.payment.member.team.invite.email-placeholder'
]()}
value={inviteEmail}
onChange={setInviteEmail}
onEnter={handleConfirm}
size="large"
/>
{!isValidEmail ? (
<div className={styles.errorHint}>
{t['com.affine.auth.sign.email.error']()}
</div>
) : null}
</div>
<div>{importCSV}</div>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { emailRegex } from '@affine/component/auth-components';
import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useCallback, useEffect, useState } from 'react';

import { ConfirmModal } from '../../../ui/modal';
import { notify } from '../../../ui/notification';
import { type InviteMethodType, ModalContent } from './modal-content';
import * as styles from './styles.css';

export interface InviteTeamMemberModalProps {
open: boolean;
setOpen: (value: boolean) => void;
onConfirm: (params: { emails: string[] }) => void;
isMutating: boolean;
copyTextToClipboard: (text: string) => Promise<boolean>;
onGenerateInviteLink: (
expireTime: WorkspaceInviteLinkExpireTime
) => Promise<string>;
onRevokeInviteLink: () => Promise<boolean>;
importCSV: React.ReactNode;
}

const parseEmailString = (emailString: string): string[] => {
return emailString
.split(',')
.map(email => email.trim())
.filter(email => email.length > 0);
};

export const InviteTeamMemberModal = ({
open,
setOpen,
onConfirm,
isMutating,
copyTextToClipboard,
onGenerateInviteLink,
onRevokeInviteLink,
importCSV,
}: InviteTeamMemberModalProps) => {
const t = useI18n();
const [inviteEmails, setInviteEmails] = useState('');
const [isValidEmail, setIsValidEmail] = useState(true);
const [inviteMethod, setInviteMethod] = useState<InviteMethodType>('email');

const handleConfirm = useCallback(() => {
if (inviteMethod === 'link') {
setOpen(false);
return;
}
const inviteEmailsArray = parseEmailString(inviteEmails);
const invalidEmail = inviteEmailsArray.find(
email => !emailRegex.test(email)
);
if (invalidEmail) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);

onConfirm({
emails: inviteEmailsArray,
});
notify.success({
title: t['com.affine.payment.member.team.invite.notify.title'](),
message: t['com.affine.payment.member.team.invite.notify.message'](),
});
}, [inviteEmails, inviteMethod, onConfirm, setOpen, t]);

useEffect(() => {
if (!open) {
setInviteEmails('');
setIsValidEmail(true);
}
}, [open]);

return (
<ConfirmModal
width={480}
open={open}
onOpenChange={setOpen}
title={t['com.affine.payment.member.team.invite.title']()}
cancelText={t['com.affine.inviteModal.button.cancel']()}
contentOptions={{
['data-testid' as string]: 'invite-modal',
style: {
padding: '20px 24px',
},
}}
confirmText={
inviteMethod === 'email'
? t['com.affine.payment.member.team.invite.send-invites']()
: t['com.affine.payment.member.team.invite.done']()
}
confirmButtonOptions={{
loading: isMutating,
variant: 'primary',
}}
onConfirm={handleConfirm}
childrenContentClassName={styles.contentStyle}
>
<ModalContent
inviteEmail={inviteEmails}
setInviteEmail={setInviteEmails}
handleConfirm={handleConfirm}
isMutating={isMutating}
isValidEmail={isValidEmail}
inviteMethod={inviteMethod}
importCSV={importCSV}
onInviteMethodChange={setInviteMethod}
copyTextToClipboard={copyTextToClipboard}
onGenerateInviteLink={onGenerateInviteLink}
onRevokeInviteLink={onRevokeInviteLink}
/>
</ConfirmModal>
);
};
Loading

0 comments on commit 612310b

Please sign in to comment.