diff --git a/packages/frontend/core/src/components/workspace-selector/index.tsx b/packages/frontend/core/src/components/workspace-selector/index.tsx index 70594c274c..0aa3e0bd69 100644 --- a/packages/frontend/core/src/components/workspace-selector/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/index.tsx @@ -109,6 +109,7 @@ export const WorkspaceSelector = ({ showArrowDownIcon={showArrowDownIcon} disable={disable} hideCollaborationIcon={true} + hideTeamWorkspaceIcon={true} data-testid="current-workspace-card" /> ) : ( diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx index 8a487736b9..b67ec2f146 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx @@ -122,6 +122,7 @@ const TeamCard = () => { const expiration = teamSubscription?.canceledAt; const nextBillingDate = teamSubscription?.nextBillAt; const recurring = teamSubscription?.recurring; + const endDate = teamSubscription?.end; const description = useMemo(() => { if (recurring === SubscriptionRecurring.Yearly) { @@ -138,22 +139,22 @@ const TeamCard = () => { }, [recurring, t]); const expirationDate = useMemo(() => { - if (expiration) { + if (expiration && endDate) { return t[ 'com.affine.settings.workspace.billing.team-workspace.not-renewed' ]({ - date: new Date(expiration).toLocaleDateString(), + date: new Date(endDate).toLocaleDateString(), }); } - if (nextBillingDate) { + if (nextBillingDate && endDate) { return t[ 'com.affine.settings.workspace.billing.team-workspace.next-billing-date' ]({ - date: new Date(nextBillingDate).toLocaleDateString(), + date: new Date(endDate).toLocaleDateString(), }); } return ''; - }, [expiration, nextBillingDate, t]); + }, [endDate, expiration, nextBillingDate, t]); const amount = teamSubscription ? teamPrices && workspaceMemberCount diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx index f2abca7bd9..a18bf0604c 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx @@ -66,6 +66,10 @@ export const CloudWorkspaceMembersPanel = ({ permissionService.permission.revalidate(); }, [permissionService]); + useEffect(() => { + membersService.members.revalidate(); + }, [membersService]); + const workspaceQuotaService = useService(WorkspaceQuotaService); useEffect(() => { workspaceQuotaService.quota.revalidate(); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx index 96e5cb6add..f9871cbead 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx @@ -125,6 +125,7 @@ const MemberItem = ({ const show = isOwner && currentAccount.id !== member.id; const handleOpenAssignModal = useCallback(() => { + setInputValue(''); setOpen(true); }, []); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx index 85c79f20ae..1dc99cdb52 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx @@ -174,7 +174,8 @@ export const MemberOptions = ({ onClick: handleDecline, show: (isAdmin || isOwner) && - member.status === WorkspaceMemberStatus.UnderReview, + (member.status === WorkspaceMemberStatus.UnderReview || + member.status === WorkspaceMemberStatus.NeedMoreSeatAndReview), }, { label: t['com.affine.payment.member.team.revoke'](), @@ -207,7 +208,8 @@ export const MemberOptions = ({ onClick: handleChangeToAdmin, show: isOwner && - member.permission === Permission.Write && + member.permission !== Permission.Owner && + member.permission !== Permission.Admin && member.status === WorkspaceMemberStatus.Accepted, }, { diff --git a/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx b/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx index 6790377667..bcb5489644 100644 --- a/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx +++ b/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx @@ -100,7 +100,11 @@ export const UpgradeToTeam = ({ recurring }: { recurring: string | null }) => { }, }} > - + {menuTriggerText} diff --git a/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts b/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts index 5499b1caab..9e7ed4daef 100644 --- a/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts +++ b/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts @@ -18,6 +18,11 @@ export const menuTrigger = style({ fontSize: cssVar('fontBase'), fontWeight: 500, color: cssVarV2('text/placeholder'), + selectors: { + '&[data-selected="true"]': { + color: cssVarV2('text/primary'), + }, + }, }); export const upgradeButton = style({ diff --git a/packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx b/packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx index 125080f0c5..38b5ee51b5 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx @@ -10,6 +10,7 @@ import { AIIsland } from '@affine/core/desktop/components/ai-island'; import { AppContainer } from '@affine/core/desktop/components/app-container'; import { WorkspaceDialogs } from '@affine/core/desktop/dialogs'; import { PeekViewManagerModal } from '@affine/core/modules/peek-view'; +import { QuotaCheck } from '@affine/core/modules/quota'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { LiveData, @@ -31,7 +32,10 @@ export const WorkspaceLayout = function WorkspaceLayout({ {currentWorkspace?.flavour === 'local' ? ( ) : ( - + <> + + + )} diff --git a/packages/frontend/core/src/modules/permissions/entities/permission.ts b/packages/frontend/core/src/modules/permissions/entities/permission.ts index 412ced8e73..6e8ad03b26 100644 --- a/packages/frontend/core/src/modules/permissions/entities/permission.ts +++ b/packages/frontend/core/src/modules/permissions/entities/permission.ts @@ -80,9 +80,6 @@ export class WorkspacePermission extends Entity { permission: Permission, sendInviteMail?: boolean ) { - if (!this.isAdmin$.value && !this.isOwner$.value) { - throw new Error('User has no permission to invite members'); - } return await this.store.inviteMember( this.workspaceService.workspace.id, email, @@ -92,9 +89,6 @@ export class WorkspacePermission extends Entity { } async inviteMembers(emails: string[], sendInviteMail?: boolean) { - if (!this.isAdmin$.value && !this.isOwner$.value) { - throw new Error('User has no permission to invite members'); - } return await this.store.inviteBatch( this.workspaceService.workspace.id, emails, @@ -103,9 +97,6 @@ export class WorkspacePermission extends Entity { } async generateInviteLink(expireTime: WorkspaceInviteLinkExpireTime) { - if (!this.isAdmin$.value && !this.isOwner$.value) { - throw new Error('User has no permission to generate invite link'); - } return await this.store.generateInviteLink( this.workspaceService.workspace.id, expireTime @@ -113,18 +104,12 @@ export class WorkspacePermission extends Entity { } async revokeInviteLink() { - if (!this.isAdmin$.value && !this.isOwner$.value) { - throw new Error('User has no permission to revoke invite link'); - } return await this.store.revokeInviteLink( this.workspaceService.workspace.id ); } async revokeMember(userId: string) { - if (!this.isAdmin$.value && !this.isOwner$.value) { - throw new Error('User has no permission to revoke members'); - } return await this.store.revokeMemberPermission( this.workspaceService.workspace.id, userId @@ -140,9 +125,6 @@ export class WorkspacePermission extends Entity { } async approveMember(userId: string) { - if (!this.isAdmin$.value && !this.isOwner$.value) { - throw new Error('User has no permission to accept invite'); - } return await this.store.approveMember( this.workspaceService.workspace.id, userId @@ -150,9 +132,6 @@ export class WorkspacePermission extends Entity { } async adjustMemberPermission(userId: string, permission: Permission) { - if (!this.isAdmin$.value) { - throw new Error('User has no permission to adjust member permissions'); - } return await this.store.adjustMemberPermission( this.workspaceService.workspace.id, userId, diff --git a/packages/frontend/core/src/modules/quota/index.ts b/packages/frontend/core/src/modules/quota/index.ts index 0d75a8e4ae..790fe42796 100644 --- a/packages/frontend/core/src/modules/quota/index.ts +++ b/packages/frontend/core/src/modules/quota/index.ts @@ -1,4 +1,5 @@ export { WorkspaceQuotaService } from './services/quota'; +export { QuotaCheck } from './views/quota-check'; import { type Framework, diff --git a/packages/frontend/core/src/modules/quota/views/quota-check.tsx b/packages/frontend/core/src/modules/quota/views/quota-check.tsx new file mode 100644 index 0000000000..62394d451b --- /dev/null +++ b/packages/frontend/core/src/modules/quota/views/quota-check.tsx @@ -0,0 +1,192 @@ +import { useConfirmModal } from '@affine/component'; +import { GlobalDialogService } from '@affine/core/modules/dialogs'; +import { type I18nString, useI18n } from '@affine/i18n'; +import { + useLiveData, + useService, + type WorkspaceMetadata, + WorkspacesService, +} from '@toeverything/infra'; +import { useCallback, useEffect } from 'react'; + +import { WorkspaceQuotaService } from '../services/quota'; +import * as styles from './styles.css'; + +interface Message { + title: I18nString; + description: I18nString; + confirmText: I18nString; + tips?: I18nString[]; + cancelText?: I18nString; +} + +/** + * + * Notification that the cloud workspace quota has exceeded the limit + * + */ +export const QuotaCheck = ({ + workspaceMeta, +}: { + workspaceMeta: WorkspaceMetadata; +}) => { + const { openConfirmModal } = useConfirmModal(); + const workspacesService = useService(WorkspacesService); + const workspaceQuota = useService(WorkspaceQuotaService).quota; + const workspaceProfile = workspacesService.getProfile(workspaceMeta); + const quota = useLiveData(workspaceQuota.quota$); + const usedPercent = useLiveData(workspaceQuota.percent$); + const isOwner = useLiveData(workspaceProfile.profile$)?.isOwner; + const globalDialogService = useService(GlobalDialogService); + const t = useI18n(); + + const onConfirm = useCallback(() => { + if (!isOwner) { + return; + } + globalDialogService.open('setting', { + activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', + }); + }, [globalDialogService, isOwner]); + + useEffect(() => { + workspaceQuota?.revalidate(); + }, [workspaceQuota]); + + useEffect(() => { + if (workspaceMeta.flavour === 'local' || !quota) { + return; + } + const memberOverflow = quota.memberCount > quota.memberLimit; + // remember to use real percent + const storageOverflow = usedPercent && usedPercent >= 100; + const message = getSyncPausedMessage( + !!isOwner, + memberOverflow, + !!storageOverflow + ); + + if (memberOverflow || storageOverflow) { + openConfirmModal({ + title: t.t(message.title), + description: t.t(message.description), + confirmText: t.t(message.confirmText), + cancelText: message.cancelText ? t.t(message.cancelText) : undefined, + children: , + childrenContentClassName: styles.modalChildren, + onConfirm: onConfirm, + confirmButtonOptions: { + variant: 'primary', + }, + cancelButtonOptions: { + style: { + visibility: message.cancelText ? 'visible' : 'hidden', + }, + }, + }); + return; + } else { + return; + } + }, [ + isOwner, + onConfirm, + openConfirmModal, + quota, + t, + usedPercent, + workspaceMeta.flavour, + ]); + return null; +}; + +const messages: Record< + 'owner' | 'member', + Record<'both' | 'storage' | 'member', Message> +> = { + owner: { + both: { + title: 'com.affine.payment.sync-paused.owner.title', + description: 'com.affine.payment.sync-paused.owner.both.description', + tips: [ + 'com.affine.payment.sync-paused.owner.both.tips-1', + 'com.affine.payment.sync-paused.owner.both.tips-2', + ], + cancelText: 'Cancel', + confirmText: 'com.affine.payment.upgrade', + }, + storage: { + title: 'com.affine.payment.sync-paused.owner.title', + description: 'com.affine.payment.sync-paused.owner.storage.description', + tips: [ + 'com.affine.payment.sync-paused.owner.storage.tips-1', + 'com.affine.payment.sync-paused.owner.storage.tips-2', + ], + cancelText: 'Cancel', + confirmText: 'com.affine.payment.upgrade', + }, + member: { + title: 'com.affine.payment.sync-paused.owner.title', + description: 'com.affine.payment.sync-paused.owner.member.description', + tips: [ + 'com.affine.payment.sync-paused.owner.member.tips-1', + 'com.affine.payment.sync-paused.owner.member.tips-2', + ], + cancelText: 'Cancel', + confirmText: 'com.affine.payment.upgrade', + }, + }, + member: { + both: { + title: 'com.affine.payment.sync-paused.member.title', + description: 'com.affine.payment.sync-paused.member.both.description', + confirmText: 'com.affine.payment.sync-paused.member.member.confirm', + }, + storage: { + title: 'com.affine.payment.sync-paused.member.title', + description: 'com.affine.payment.sync-paused.member.storage.description', + confirmText: 'com.affine.payment.sync-paused.member.member.confirm', + }, + member: { + title: 'com.affine.payment.sync-paused.member.title', + description: 'com.affine.payment.sync-paused.member.member.description', + confirmText: 'com.affine.payment.sync-paused.member.member.confirm', + }, + }, +}; + +function getSyncPausedMessage( + isOwner: boolean, + isMemberOverflow: boolean, + isStorageOverflow: boolean +): Message { + const userType = isOwner ? 'owner' : 'member'; + const condition = + isStorageOverflow && isMemberOverflow + ? 'both' + : isStorageOverflow + ? 'storage' + : isMemberOverflow + ? 'member' + : 'both'; + + return messages[userType][condition]; +} + +const Tips = ({ tips }: { tips?: I18nString[] }) => { + const t = useI18n(); + if (!tips || tips.length < 1) { + return null; + } + return ( +
+ {tips.map(tip => ( +
+
+ {t.t(tip)} +
+ ))} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/quota/views/styles.css.ts b/packages/frontend/core/src/modules/quota/views/styles.css.ts new file mode 100644 index 0000000000..dd2b402d9e --- /dev/null +++ b/packages/frontend/core/src/modules/quota/views/styles.css.ts @@ -0,0 +1,27 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const tipsStyle = style({ + display: 'flex', + flexDirection: 'column', + gap: '8px', +}); + +export const tipStyle = style({ + display: 'flex', + flexWrap: 'nowrap', +}); + +export const bullet = style({ + backgroundColor: cssVarV2('icon/activated'), + width: '6px', + height: '6px', + borderRadius: '50%', + marginTop: '8px', + marginLeft: '4px', + marginRight: '12px', +}); + +export const modalChildren = style({ + paddingLeft: '0', +}); diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 8496850ee1..1c81c85c44 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -5,20 +5,20 @@ "de": 26, "el-GR": 0, "en": 100, - "es-AR": 13, + "es-AR": 12, "es-CL": 14, "es": 12, - "fr": 61, + "fr": 60, "hi": 2, "it-IT": 1, "it": 1, - "ja": 91, - "ko": 73, + "ja": 90, + "ko": 72, "pl": 0, "pt-BR": 78, - "ru": 67, + "ru": 66, "sv-SE": 4, "ur": 2, "zh-Hans": 91, - "zh-Hant": 91 + "zh-Hant": 90 } \ No newline at end of file diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 83f282d2ae..f471312ca8 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1600,5 +1600,20 @@ "com.affine.upgrade-to-team-page.no-workspace-available": "No workspace available", "com.affine.workspace.storage": "Workspace storage", "com.affine.cmdk.affine.category.affine.journal": "Journal", - "com.affine.cmdk.affine.category.affine.date-picker": "Select a specific date" + "com.affine.cmdk.affine.category.affine.date-picker": "Select a specific date", + "com.affine.payment.sync-paused.owner.title": "[Action Required] Workspace sync paused", + "com.affine.payment.sync-paused.owner.both.description": "Your workspace has exceeded both storage and member limits, causing synchronization to pause. To resume syncing, please either:", + "com.affine.payment.sync-paused.owner.both.tips-1": "Reduce storage usage and remove some team members", + "com.affine.payment.sync-paused.owner.both.tips-2": "Upgrade your plan for increased capacity", + "com.affine.payment.sync-paused.owner.storage.description": "Your workspace has exceeded its storage limit and synchronization has been paused. To resume syncing, please either:", + "com.affine.payment.sync-paused.owner.storage.tips-1": "Remove unnecessary files or content to reduce storage usage", + "com.affine.payment.sync-paused.owner.storage.tips-2": "Upgrade your plan for increased storage capacity", + "com.affine.payment.sync-paused.owner.member.description": "Your workspace has reached its maximum member capacity and synchronization has been paused. To resume syncing, you can either", + "com.affine.payment.sync-paused.owner.member.tips-1": "Remove some team members from the workspace", + "com.affine.payment.sync-paused.owner.member.tips-2": "Upgrade your plan to accommodate more members", + "com.affine.payment.sync-paused.member.title": "Workspace sync paused", + "com.affine.payment.sync-paused.member.both.description": "This workspace has exceeded both storage and member limits, causing synchronization to pause. Please contact your workspace owner to address these limits and resume syncing.", + "com.affine.payment.sync-paused.member.storage.description": "This workspace has exceeded its storage limit and synchronization has been paused. Please contact your workspace owner to either reduce storage usage or upgrade the plan to resume syncing.", + "com.affine.payment.sync-paused.member.member.description": "This workspace has reached its maximum member capacity and synchronization has been paused. Please contact your workspace owner to either adjust team membership or upgrade the plan to resume syncing.", + "com.affine.payment.sync-paused.member.member.confirm": "Got It" } diff --git a/tests/affine-cloud/e2e/workspace.spec.ts b/tests/affine-cloud/e2e/workspace.spec.ts index 5c10ec95a3..b577cf6297 100644 --- a/tests/affine-cloud/e2e/workspace.spec.ts +++ b/tests/affine-cloud/e2e/workspace.spec.ts @@ -62,6 +62,8 @@ test('should have pagination in member list', async ({ page }) => { await page.waitForTimeout(1000); + await page.getByTestId('confirm-modal-cancel').click(); + const firstPageMemberItemCount = await page .locator('[data-testid="member-item"]') .count();