feat(core): make permission and invoice offline available (#8123)

This commit is contained in:
EYHN 2024-09-05 15:11:27 +00:00
parent 8be67dce82
commit f4db4058f8
No known key found for this signature in database
GPG Key ID: 46C9E26A75AB276C
17 changed files with 236 additions and 186 deletions

View File

@ -75,6 +75,14 @@ export class PermissionService {
return owner.user;
}
async getWorkspaceMemberCount(workspaceId: string) {
return this.prisma.workspaceUserPermission.count({
where: {
workspaceId,
},
});
}
async tryGetWorkspaceOwner(workspaceId: string) {
return this.prisma.workspaceUserPermission.findFirst({
where: {

View File

@ -113,6 +113,8 @@ export class QuotaManagementService {
// quota was apply to owner's account
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const memberCount =
await this.permissions.getWorkspaceMemberCount(workspaceId);
const {
feature: {
name,
@ -145,6 +147,7 @@ export class QuotaManagementService {
humanReadable,
usedSize,
unlimited,
memberCount,
};
if (quota.unlimited) {

View File

@ -87,6 +87,9 @@ export class QuotaQueryType {
@Field(() => SafeIntResolver)
memberLimit!: number;
@Field(() => SafeIntResolver)
memberCount!: number;
@Field(() => SafeIntResolver)
storageQuota!: number;

View File

@ -115,11 +115,7 @@ export class WorkspaceResolver {
complexity: 2,
})
memberCount(@Parent() workspace: WorkspaceType) {
return this.prisma.workspaceUserPermission.count({
where: {
workspaceId: workspace.id,
},
});
return this.permissions.getWorkspaceMemberCount(workspace.id);
}
@ResolveField(() => Boolean, {
@ -388,13 +384,8 @@ export class WorkspaceResolver {
}
// member limit check
const [memberCount, quota] = await Promise.all([
this.prisma.workspaceUserPermission.count({
where: { workspaceId },
}),
this.quota.getWorkspaceUsage(workspaceId),
]);
if (memberCount >= quota.memberLimit) {
const quota = await this.quota.getWorkspaceUsage(workspaceId);
if (quota.memberCount >= quota.memberLimit) {
return new MemberQuotaExceeded();
}

View File

@ -603,6 +603,7 @@ type QuotaQueryType {
copilotActionLimit: SafeInt
historyPeriod: SafeInt!
humanReadable: HumanReadableQuotaType!
memberCount: SafeInt!
memberLimit: SafeInt!
name: String!
storageQuota: SafeInt!

View File

@ -6,19 +6,21 @@ import ReactPaginate from 'react-paginate';
import * as styles from './styles.css';
export interface PaginationProps {
totalCount: number;
pageNum?: number;
countPerPage: number;
onPageChange: (skip: number) => void;
onPageChange: (skip: number, pageNum: number) => void;
}
export const Pagination = ({
totalCount,
countPerPage,
pageNum,
onPageChange,
}: PaginationProps) => {
const handlePageClick = useCallback(
(e: { selected: number }) => {
const newOffset = (e.selected * countPerPage) % totalCount;
onPageChange(newOffset);
onPageChange(newOffset, e.selected);
},
[countPerPage, onPageChange, totalCount]
);
@ -34,6 +36,7 @@ export const Pagination = ({
pageRangeDisplayed={3}
marginPagesDisplayed={2}
pageCount={pageCount}
forcePage={pageNum}
previousLabel={<ArrowLeftSmallIcon />}
nextLabel={<ArrowRightSmallIcon />}
pageClassName={styles.pageItem}

View File

@ -1,8 +1,5 @@
import { notify } from '@affine/component';
import type {
InviteModalProps,
PaginationProps,
} from '@affine/component/member-components';
import type { InviteModalProps } from '@affine/component/member-components';
import {
InviteModal,
MemberLimitModal,
@ -17,15 +14,16 @@ import { Tooltip } from '@affine/component/ui/tooltip';
import { openSettingModalAtom } from '@affine/core/atoms';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { useInviteMember } from '@affine/core/hooks/affine/use-invite-member';
import { useMemberCount } from '@affine/core/hooks/affine/use-member-count';
import type { Member } from '@affine/core/hooks/affine/use-members';
import { useMembers } from '@affine/core/hooks/affine/use-members';
import { useRevokeMemberPermission } from '@affine/core/hooks/affine/use-revoke-member-permission';
import { track } from '@affine/core/mixpanel';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import {
type Member,
WorkspaceMembersService,
WorkspacePermissionService,
} from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Permission } from '@affine/graphql';
import { Permission, UserFriendlyError } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { MoreVerticalIcon } from '@blocksuite/icons/rc';
import {
@ -34,18 +32,11 @@ import {
useService,
WorkspaceService,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import {
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type AuthAccountInfo,
@ -55,7 +46,6 @@ import {
} from '../../../../../modules/cloud';
import * as style from './style.css';
const COUNT_PER_PAGE = 8;
type OnRevoke = (memberId: string) => void;
const MembersPanelLocal = () => {
const t = useI18n();
@ -76,7 +66,6 @@ export const CloudWorkspaceMembersPanel = () => {
serverConfig.features$.map(f => f?.payment)
);
const workspace = useService(WorkspaceService).workspace;
const memberCount = useMemberCount(workspace.id);
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
@ -84,42 +73,32 @@ export const CloudWorkspaceMembersPanel = () => {
permissionService.permission.revalidate();
}, [permissionService]);
const checkMemberCountLimit = useCallback(
(memberCount: number, memberLimit?: number) => {
if (memberLimit === undefined) return false;
return memberCount >= memberLimit;
},
[]
);
const workspaceQuotaService = useService(WorkspaceQuotaService);
useEffect(() => {
workspaceQuotaService.quota.revalidate();
}, [workspaceQuotaService]);
const isLoading = useLiveData(workspaceQuotaService.quota.isLoading$);
const error = useLiveData(workspaceQuotaService.quota.error$);
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
const subscriptionService = useService(SubscriptionService);
const plan = useLiveData(
subscriptionService.subscription.pro$.map(s => s?.plan)
);
const isLimited = workspaceQuota
? checkMemberCountLimit(memberCount, workspaceQuota.memberLimit)
: null;
const isLimited =
workspaceQuota && workspaceQuota.memberLimit
? workspaceQuota.memberCount >= workspaceQuota.memberLimit
: null;
const t = useI18n();
const { invite, isMutating } = useInviteMember(workspace.id);
const revokeMemberPermission = useRevokeMemberPermission(workspace.id);
const [open, setOpen] = useState(false);
const [memberSkip, setMemberSkip] = useState(0);
const openModal = useCallback(() => {
setOpen(true);
}, []);
const onPageChange = useCallback<PaginationProps['onPageChange']>(offset => {
setMemberSkip(offset);
}, []);
const onInviteConfirm = useCallback<InviteModalProps['onConfirm']>(
async ({ email, permission }) => {
const success = await invite(
@ -151,20 +130,6 @@ export const CloudWorkspaceMembersPanel = () => {
});
}, [setSettingModalAtom]);
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [memberListHeight, setMemberListHeight] = useState<number | null>(null);
useLayoutEffect(() => {
if (
memberCount > COUNT_PER_PAGE &&
listContainerRef.current &&
memberListHeight === null
) {
const rect = listContainerRef.current.getBoundingClientRect();
setMemberListHeight(rect.height);
}
}, [listContainerRef, memberCount, memberListHeight]);
const onRevoke = useCallback<OnRevoke>(
async memberId => {
const res = await revokeMemberPermission(memberId);
@ -195,14 +160,23 @@ export const CloudWorkspaceMembersPanel = () => {
}, [handleUpgradeConfirm, hasPaymentFeature, t, workspaceQuota]);
if (workspaceQuota === null) {
// TODO(@eyhn): loading ui
return null;
if (isLoading) {
return <MembersPanelFallback />;
}
if (error) {
return (
<span style={{ color: cssVar('errorColor') }}>
{UserFriendlyError.fromAnyError(error).message}
</span>
);
}
return; // never reach here
}
return (
<>
<SettingRow
name={`${t['Members']()} (${memberCount}/${workspaceQuota.humanReadable.memberLimit})`}
name={`${t['Members']()} (${workspaceQuota.memberCount}/${workspaceQuota.humanReadable.memberLimit})`}
desc={desc}
spreadCol={!!isOwner}
>
@ -230,27 +204,8 @@ export const CloudWorkspaceMembersPanel = () => {
) : null}
</SettingRow>
<div
className={style.membersPanel}
ref={listContainerRef}
style={memberListHeight ? { height: memberListHeight } : {}}
>
<Suspense fallback={<MemberListFallback memberCount={memberCount} />}>
<MemberList
workspaceId={workspace.id}
isOwner={!!isOwner}
skip={memberSkip}
onRevoke={onRevoke}
/>
</Suspense>
{memberCount > COUNT_PER_PAGE && (
<Pagination
totalCount={memberCount}
countPerPage={COUNT_PER_PAGE}
onPageChange={onPageChange}
/>
)}
<div className={style.membersPanel}>
<MemberList isOwner={!!isOwner} onRevoke={onRevoke} />
</div>
</>
);
@ -271,12 +226,12 @@ export const MembersPanelFallback = () => {
);
};
const MemberListFallback = ({ memberCount }: { memberCount: number }) => {
const MemberListFallback = ({ memberCount }: { memberCount?: number }) => {
// prevent page jitter
const height = useMemo(() => {
if (memberCount > COUNT_PER_PAGE) {
if (memberCount) {
// height and margin-bottom
return COUNT_PER_PAGE * 58 + (COUNT_PER_PAGE - 1) * 6;
return memberCount * 58 + (memberCount - 1) * 6;
}
return 'auto';
}, [memberCount]);
@ -296,31 +251,66 @@ const MemberListFallback = ({ memberCount }: { memberCount: number }) => {
};
const MemberList = ({
workspaceId,
isOwner,
skip,
onRevoke,
}: {
workspaceId: string;
isOwner: boolean;
skip: number;
onRevoke: OnRevoke;
}) => {
const members = useMembers(workspaceId, skip, COUNT_PER_PAGE);
const membersService = useService(WorkspaceMembersService);
const memberCount = useLiveData(membersService.members.memberCount$);
const pageNum = useLiveData(membersService.members.pageNum$);
const isLoading = useLiveData(membersService.members.isLoading$);
const pageMembers = useLiveData(membersService.members.pageMembers$);
useEffect(() => {
membersService.members.revalidate();
}, [membersService]);
const session = useService(AuthService).session;
const account = useEnsureLiveData(session.account$);
const handlePageChange = useCallback(
(_: number, pageNum: number) => {
membersService.members.setPageNum(pageNum);
membersService.members.revalidate();
},
[membersService]
);
return (
<div className={style.memberList}>
{members.map(member => (
<MemberItem
currentAccount={account}
key={member.id}
member={member}
isOwner={isOwner}
onRevoke={onRevoke}
{isLoading && pageMembers === undefined ? (
<MemberListFallback
memberCount={
memberCount
? Math.max(
memberCount - pageNum * membersService.members.PAGE_SIZE,
1
)
: 1
}
/>
))}
) : (
pageMembers?.map(member => (
<MemberItem
currentAccount={account}
key={member.id}
member={member}
isOwner={isOwner}
onRevoke={onRevoke}
/>
))
)}
{memberCount !== undefined &&
memberCount > membersService.members.PAGE_SIZE && (
<Pagination
totalCount={memberCount}
countPerPage={membersService.members.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
@ -409,9 +399,7 @@ export const MembersPanel = (): ReactElement | null => {
}
return (
<AffineErrorBoundary>
<Suspense fallback={<MembersPanelFallback />}>
<CloudWorkspaceMembersPanel />
</Suspense>
<CloudWorkspaceMembersPanel />
</AffineErrorBoundary>
);
};

View File

@ -1,14 +0,0 @@
import { getMemberCountByWorkspaceIdQuery } from '@affine/graphql';
import { useQuery } from '../use-query';
export function useMemberCount(workspaceId: string) {
const { data } = useQuery({
query: getMemberCountByWorkspaceIdQuery,
variables: {
workspaceId,
},
});
return data.workspace.memberCount;
}

View File

@ -1,59 +0,0 @@
import type { GetMembersByWorkspaceIdQuery } from '@affine/graphql';
import { getMembersByWorkspaceIdQuery, Permission } from '@affine/graphql';
import { useMemo } from 'react';
import { useQuery } from '../use-query';
export function calculateWeight(member: Member) {
const permissionWeight = {
[Permission.Owner]: 4,
[Permission.Admin]: 3,
[Permission.Write]: 2,
[Permission.Read]: 1,
};
const factors = [
Number(member.permission === Permission.Owner), // Owner weight is the highest
Number(!member.accepted), // Unaccepted members are before accepted members
permissionWeight[member.permission] || 0,
];
return factors.reduce((ret, factor, index, arr) => {
return ret + factor * Math.pow(10, arr.length - 1 - index);
}, 0);
}
export type Member = Omit<
GetMembersByWorkspaceIdQuery['workspace']['members'][number],
'__typename'
>;
export function useMembers(
workspaceId: string,
skip: number,
take: number = 8
) {
const { data } = useQuery({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
skip,
take,
},
});
const members = data.workspace.members;
return useMemo(() => {
// sort members by weight
return members.sort((a, b) => {
const weightDifference = calculateWeight(b) - calculateWeight(a);
if (weightDifference !== 0) {
return weightDifference;
}
// if weight is the same, sort by name
if (a.name === null) return 1;
if (b.name === null) return -1;
return a.name.localeCompare(b.name);
});
}, [members]);
}

View File

@ -3,7 +3,6 @@ import {
type GraphQLQuery,
type QueryOptions,
type QueryResponse,
UserFriendlyError,
} from '@affine/graphql';
import { fromPromise, Service } from '@toeverything/infra';
import type { Observable } from 'rxjs';
@ -39,13 +38,11 @@ export class GraphQLService extends Service {
try {
return await this.rawGql(options);
} catch (err) {
const standardError = UserFriendlyError.fromAnyError(err);
if (standardError.status === 403) {
if (err instanceof BackendError && err.status === 403) {
this.framework.get(AuthService).session.revalidate();
}
throw new BackendError(standardError);
throw err;
}
};
}

View File

@ -0,0 +1,79 @@
import type { GetMembersByWorkspaceIdQuery } from '@affine/graphql';
import type { WorkspaceService } from '@toeverything/infra';
import {
backoffRetry,
catchErrorInto,
effect,
Entity,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { distinctUntilChanged, EMPTY, map, mergeMap, switchMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceMembersStore } from '../stores/members';
export type Member =
GetMembersByWorkspaceIdQuery['workspace']['members'][number];
export class WorkspaceMembers extends Entity {
constructor(
private readonly store: WorkspaceMembersStore,
private readonly workspaceService: WorkspaceService
) {
super();
}
pageNum$ = new LiveData(0);
memberCount$ = new LiveData<number | undefined>(undefined);
pageMembers$ = new LiveData<Member[] | undefined>(undefined);
isLoading$ = new LiveData(false);
error$ = new LiveData<any>(null);
readonly PAGE_SIZE = 8;
readonly revalidate = effect(
map(() => this.pageNum$.value),
distinctUntilChanged<number>(),
switchMap(pageNum => {
return fromPromise(async signal => {
return this.store.fetchMembers(
this.workspaceService.workspace.id,
pageNum * this.PAGE_SIZE,
this.PAGE_SIZE,
signal
);
}).pipe(
mergeMap(data => {
this.memberCount$.setValue(data.memberCount);
this.pageMembers$.setValue(data.members);
return EMPTY;
}),
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
catchErrorInto(this.error$),
onStart(() => {
this.pageMembers$.setValue(undefined);
this.isLoading$.setValue(true);
}),
onComplete(() => this.isLoading$.setValue(false))
);
})
);
setPageNum(pageNum: number) {
this.pageNum$.setValue(pageNum);
}
override dispose(): void {
this.revalidate.unsubscribe();
}
}

View File

@ -1,3 +1,5 @@
export type { Member } from './entities/members';
export { WorkspaceMembersService } from './services/members';
export { WorkspacePermissionService } from './services/permission';
import { GraphQLService } from '@affine/core/modules/cloud';
@ -8,8 +10,11 @@ import {
WorkspacesService,
} from '@toeverything/infra';
import { WorkspaceMembers } from './entities/members';
import { WorkspacePermission } from './entities/permission';
import { WorkspaceMembersService } from './services/members';
import { WorkspacePermissionService } from './services/permission';
import { WorkspaceMembersStore } from './stores/members';
import { WorkspacePermissionStore } from './stores/permission';
export function configurePermissionsModule(framework: Framework) {
@ -21,5 +26,8 @@ export function configurePermissionsModule(framework: Framework) {
WorkspacePermissionStore,
])
.store(WorkspacePermissionStore, [GraphQLService])
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore]);
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
.service(WorkspaceMembersService)
.store(WorkspaceMembersStore, [GraphQLService])
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService]);
}

View File

@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { WorkspaceMembers } from '../entities/members';
export class WorkspaceMembersService extends Service {
members = this.framework.createEntity(WorkspaceMembers);
}

View File

@ -0,0 +1,31 @@
import { getMembersByWorkspaceIdQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../../cloud';
export class WorkspaceMembersStore extends Store {
constructor(private readonly graphqlService: GraphQLService) {
super();
}
async fetchMembers(
workspaceId: string,
skip: number,
take: number,
signal?: AbortSignal
) {
const data = await this.graphqlService.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
skip,
take,
},
context: {
signal,
},
});
return data.workspace;
}
}

View File

@ -1297,6 +1297,7 @@ query workspaceQuota($id: String!) {
storageQuota
historyPeriod
memberLimit
memberCount
humanReadable {
name
blobLimit

View File

@ -6,6 +6,7 @@ query workspaceQuota($id: String!) {
storageQuota
historyPeriod
memberLimit
memberCount
humanReadable {
name
blobLimit

View File

@ -901,6 +901,7 @@ export interface QuotaQueryType {
copilotActionLimit: Maybe<Scalars['SafeInt']['output']>;
historyPeriod: Scalars['SafeInt']['output'];
humanReadable: HumanReadableQuotaType;
memberCount: Scalars['SafeInt']['output'];
memberLimit: Scalars['SafeInt']['output'];
name: Scalars['String']['output'];
storageQuota: Scalars['SafeInt']['output'];
@ -2423,6 +2424,7 @@ export type WorkspaceQuotaQuery = {
storageQuota: number;
historyPeriod: number;
memberLimit: number;
memberCount: number;
usedSize: number;
humanReadable: {
__typename?: 'HumanReadableQuotaType';