fix(server): dashboard query (#15195)
#### PR Dependency Tree * **PR #15195** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Admin dashboard storage history now returns a continuous day-by-day trend, carrying forward the last known workspace and blob storage values for days without samples. * Active member counts for workspace analytics were corrected to reflect active workspace members. * **New Features** * Admin workspace member roles are now returned via a dedicated role enum, serialized as enum name strings (e.g., `Owner`). * **UI Improvements** * Renamed key labels to “Active Members” and “Members and Invitations”; member roles use pill badges and non-accepted statuses are shown. * **Tests** * Added E2E coverage for role enum serialization and strengthened storage history assertions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -390,6 +390,43 @@ e2e(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
e2e('adminWorkspace should serialize member roles as enum names', async t => {
|
||||||
|
const admin = await app.create(Mockers.User, {
|
||||||
|
feature: 'administrator',
|
||||||
|
});
|
||||||
|
await app.login(admin);
|
||||||
|
|
||||||
|
const owner = await app.create(Mockers.User);
|
||||||
|
const workspace = await app.create(Mockers.Workspace, {
|
||||||
|
owner: { id: owner.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await gql(
|
||||||
|
`
|
||||||
|
query AdminWorkspace($id: String!) {
|
||||||
|
adminWorkspace(id: $id) {
|
||||||
|
id
|
||||||
|
members(skip: 0, take: 20) {
|
||||||
|
id
|
||||||
|
role
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{ id: workspace.id }
|
||||||
|
);
|
||||||
|
|
||||||
|
t.falsy(result.errors);
|
||||||
|
t.is(result.data!.adminWorkspace.id, workspace.id);
|
||||||
|
t.true(
|
||||||
|
result.data!.adminWorkspace.members.some(
|
||||||
|
(member: { id: string; role: string }) =>
|
||||||
|
member.id === owner.id && member.role === 'Owner'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
e2e(
|
e2e(
|
||||||
'adminDashboard should carry forward missing sync and storage samples',
|
'adminDashboard should carry forward missing sync and storage samples',
|
||||||
async t => {
|
async t => {
|
||||||
@@ -524,6 +561,36 @@ e2e(
|
|||||||
dashboard.workspaceStorageBytes
|
dashboard.workspaceStorageBytes
|
||||||
);
|
);
|
||||||
t.is(blobHistory[blobHistory.length - 1], dashboard.blobStorageBytes);
|
t.is(blobHistory[blobHistory.length - 1], dashboard.blobStorageBytes);
|
||||||
|
|
||||||
|
const baselineResult = await gql(
|
||||||
|
`
|
||||||
|
query AdminDashboard($input: AdminDashboardInput) {
|
||||||
|
adminDashboard(input: $input) {
|
||||||
|
workspaceStorageHistory {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
blobStorageHistory {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
storageHistoryDays: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
t.falsy(baselineResult.errors);
|
||||||
|
t.is(
|
||||||
|
baselineResult.data!.adminDashboard.workspaceStorageHistory[0].value,
|
||||||
|
workspaceHistory[1]
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
baselineResult.data!.adminDashboard.blobStorageHistory[0].value,
|
||||||
|
blobHistory[1]
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
clock.restore();
|
clock.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
Models,
|
Models,
|
||||||
WorkspaceFeatureName,
|
WorkspaceFeatureName,
|
||||||
WorkspaceMemberStatus,
|
WorkspaceMemberStatus,
|
||||||
WorkspaceRole,
|
|
||||||
} from '../../../models';
|
} from '../../../models';
|
||||||
import { Admin } from '../../common';
|
import { Admin } from '../../common';
|
||||||
import { WorkspaceUserType } from '../../user';
|
import { WorkspaceUserType } from '../../user';
|
||||||
@@ -60,6 +59,16 @@ registerEnumType(AdminSharedLinksOrder, {
|
|||||||
name: 'AdminSharedLinksOrder',
|
name: 'AdminSharedLinksOrder',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
enum AdminWorkspaceMemberRole {
|
||||||
|
Owner = 'Owner',
|
||||||
|
Admin = 'Admin',
|
||||||
|
Collaborator = 'Collaborator',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(AdminWorkspaceMemberRole, {
|
||||||
|
name: 'AdminWorkspaceMemberRole',
|
||||||
|
});
|
||||||
|
|
||||||
function hasSelectedField(
|
function hasSelectedField(
|
||||||
selections: readonly SelectionNode[],
|
selections: readonly SelectionNode[],
|
||||||
fieldName: string,
|
fieldName: string,
|
||||||
@@ -145,8 +154,8 @@ class AdminWorkspaceMember {
|
|||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
|
||||||
@Field(() => WorkspaceRole)
|
@Field(() => AdminWorkspaceMemberRole)
|
||||||
role!: WorkspaceRole;
|
role!: `${AdminWorkspaceMemberRole}`;
|
||||||
|
|
||||||
@Field(() => WorkspaceMemberStatus)
|
@Field(() => WorkspaceMemberStatus)
|
||||||
status!: WorkspaceMemberStatus;
|
status!: WorkspaceMemberStatus;
|
||||||
@@ -494,18 +503,7 @@ export class AdminWorkspaceResolver {
|
|||||||
})
|
})
|
||||||
async adminWorkspace(@Args('id') id: string) {
|
async adminWorkspace(@Args('id') id: string) {
|
||||||
this.assertCloudOnly();
|
this.assertCloudOnly();
|
||||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
return await this.models.workspace.adminGetWorkspace(id);
|
||||||
first: 1,
|
|
||||||
skip: 0,
|
|
||||||
keyword: id,
|
|
||||||
order: 'createdAt',
|
|
||||||
includeTotal: false,
|
|
||||||
});
|
|
||||||
const row = rows.find(r => r.id === id);
|
|
||||||
if (!row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(() => AdminDashboard, {
|
@Query(() => AdminDashboard, {
|
||||||
@@ -608,46 +606,11 @@ export class AdminWorkspaceResolver {
|
|||||||
after: undefined,
|
after: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (query) {
|
return await this.models.workspaceUser.adminListMembers(workspaceId, {
|
||||||
const list = await this.models.workspaceUser.search(
|
first: pagination.first,
|
||||||
workspaceId,
|
offset: pagination.offset,
|
||||||
query,
|
query,
|
||||||
pagination
|
});
|
||||||
);
|
|
||||||
return list.flatMap(({ user, status, type }) =>
|
|
||||||
user
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
avatarUrl: user.avatarUrl,
|
|
||||||
role: type,
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [list] = await this.models.workspaceUser.paginate(
|
|
||||||
workspaceId,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
return list.flatMap(({ user, status, type }) =>
|
|
||||||
user
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: user.id,
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
avatarUrl: user.avatarUrl,
|
|
||||||
role: type,
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => [AdminWorkspaceSharedLink], {
|
@ResolveField(() => [AdminWorkspaceSharedLink], {
|
||||||
@@ -677,18 +640,7 @@ export class AdminWorkspaceResolver {
|
|||||||
await this.models.workspace.update(id, updates);
|
await this.models.workspace.update(id, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
return await this.models.workspace.adminGetWorkspace(id);
|
||||||
first: 1,
|
|
||||||
skip: 0,
|
|
||||||
keyword: id,
|
|
||||||
order: 'createdAt',
|
|
||||||
includeTotal: false,
|
|
||||||
});
|
|
||||||
const row = rows.find(r => r.id === id);
|
|
||||||
if (!row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapSort(orderBy?: AdminWorkspaceSort) {
|
private mapSort(orderBy?: AdminWorkspaceSort) {
|
||||||
|
|||||||
@@ -276,8 +276,9 @@ export class WorkspaceStatsJob {
|
|||||||
),
|
),
|
||||||
member_stats AS (
|
member_stats AS (
|
||||||
SELECT workspace_id, COUNT(*) AS member_count
|
SELECT workspace_id, COUNT(*) AS member_count
|
||||||
FROM workspace_user_permissions
|
FROM workspace_members
|
||||||
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||||
|
AND state = 'active'
|
||||||
GROUP BY workspace_id
|
GROUP BY workspace_id
|
||||||
),
|
),
|
||||||
public_page_stats AS (
|
public_page_stats AS (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '../base/graphql/pagination';
|
} from '../base/graphql/pagination';
|
||||||
import { CacheRedis } from '../base/redis';
|
import { CacheRedis } from '../base/redis';
|
||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
import { WorkspaceRole } from './common';
|
|
||||||
|
|
||||||
const DEFAULT_STORAGE_HISTORY_DAYS = 30;
|
const DEFAULT_STORAGE_HISTORY_DAYS = 30;
|
||||||
const DEFAULT_SYNC_HISTORY_HOURS = 48;
|
const DEFAULT_SYNC_HISTORY_HOURS = 48;
|
||||||
@@ -323,11 +322,11 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
|
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT user_id
|
SELECT user_id
|
||||||
FROM workspace_user_permissions
|
FROM workspace_members
|
||||||
WHERE workspace_id = wp.workspace_id
|
WHERE workspace_id = wp.workspace_id
|
||||||
AND type = ${WorkspaceRole.Owner}
|
AND role = 'owner'
|
||||||
AND status = 'Accepted'::"WorkspaceMemberStatus"
|
AND state = 'active'
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) owner ON TRUE
|
) owner ON TRUE
|
||||||
WHERE wp.public = TRUE
|
WHERE wp.public = TRUE
|
||||||
@@ -391,32 +390,42 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
blobStorageBytes: bigint | number;
|
blobStorageBytes: bigint | number;
|
||||||
}[]
|
}[]
|
||||||
>`
|
>`
|
||||||
WITH days AS (
|
WITH baseline_day AS (
|
||||||
SELECT generate_series(${storageFrom}::date, ${currentDay}::date, interval '1 day')::date AS day
|
SELECT max(date) AS date
|
||||||
|
FROM workspace_admin_stats_daily
|
||||||
|
WHERE date < ${storageFrom}::date
|
||||||
),
|
),
|
||||||
grouped AS (
|
baseline AS (
|
||||||
|
SELECT
|
||||||
|
baseline_day.date,
|
||||||
|
COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes,
|
||||||
|
COALESCE(SUM(blob_size), 0) AS blob_storage_bytes
|
||||||
|
FROM baseline_day
|
||||||
|
JOIN workspace_admin_stats_daily stats
|
||||||
|
ON stats.date = baseline_day.date
|
||||||
|
WHERE baseline_day.date IS NOT NULL
|
||||||
|
GROUP BY baseline_day.date
|
||||||
|
),
|
||||||
|
in_window AS (
|
||||||
SELECT
|
SELECT
|
||||||
date,
|
date,
|
||||||
COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes,
|
COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes,
|
||||||
COALESCE(SUM(blob_size), 0) AS blob_storage_bytes
|
COALESCE(SUM(blob_size), 0) AS blob_storage_bytes
|
||||||
FROM workspace_admin_stats_daily
|
FROM workspace_admin_stats_daily
|
||||||
WHERE date <= ${currentDay}::date
|
WHERE date BETWEEN ${storageFrom}::date AND ${currentDay}::date
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
days.day AS date,
|
date,
|
||||||
COALESCE(snapshot.workspace_storage_bytes, 0) AS "workspaceStorageBytes",
|
workspace_storage_bytes AS "workspaceStorageBytes",
|
||||||
COALESCE(snapshot.blob_storage_bytes, 0) AS "blobStorageBytes"
|
blob_storage_bytes AS "blobStorageBytes"
|
||||||
FROM days
|
FROM baseline
|
||||||
LEFT JOIN LATERAL (
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
workspace_storage_bytes,
|
date,
|
||||||
blob_storage_bytes
|
workspace_storage_bytes AS "workspaceStorageBytes",
|
||||||
FROM grouped
|
blob_storage_bytes AS "blobStorageBytes"
|
||||||
WHERE date <= days.day
|
FROM in_window
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
|
||||||
) snapshot ON TRUE
|
|
||||||
ORDER BY date ASC
|
ORDER BY date ASC
|
||||||
`,
|
`,
|
||||||
this.db.$queryRaw<{ conversations: bigint | number }[]>`
|
this.db.$queryRaw<{ conversations: bigint | number }[]>`
|
||||||
@@ -435,11 +444,36 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
const currentBlobStorageBytes = Number(
|
const currentBlobStorageBytes = Number(
|
||||||
storageCurrent[0]?.blobStorageBytes ?? 0
|
storageCurrent[0]?.blobStorageBytes ?? 0
|
||||||
);
|
);
|
||||||
const storageHistorySeries = storageHistory.map(row => ({
|
const storageByDate = new Map(
|
||||||
date: row.date,
|
storageHistory.map(row => [
|
||||||
workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0),
|
asDateOnlyString(row.date),
|
||||||
blobStorageBytes: Number(row.blobStorageBytes ?? 0),
|
{
|
||||||
}));
|
workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0),
|
||||||
|
blobStorageBytes: Number(row.blobStorageBytes ?? 0),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const storageHistorySeries: Array<{
|
||||||
|
date: Date;
|
||||||
|
workspaceStorageBytes: number;
|
||||||
|
blobStorageBytes: number;
|
||||||
|
}> = [];
|
||||||
|
const baselineStorage =
|
||||||
|
storageHistory.find(row => row.date < storageFrom) ?? null;
|
||||||
|
let carriedStorageValue = {
|
||||||
|
workspaceStorageBytes: Number(
|
||||||
|
baselineStorage?.workspaceStorageBytes ?? 0
|
||||||
|
),
|
||||||
|
blobStorageBytes: Number(baselineStorage?.blobStorageBytes ?? 0),
|
||||||
|
};
|
||||||
|
for (let day = storageFrom; day <= currentDay; day = addUtcDays(day, 1)) {
|
||||||
|
carriedStorageValue =
|
||||||
|
storageByDate.get(asDateOnlyString(day)) ?? carriedStorageValue;
|
||||||
|
storageHistorySeries.push({
|
||||||
|
date: day,
|
||||||
|
...carriedStorageValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (storageHistorySeries.length > 0) {
|
if (storageHistorySeries.length > 0) {
|
||||||
const lastPoint = storageHistorySeries[storageHistorySeries.length - 1];
|
const lastPoint = storageHistorySeries[storageHistorySeries.length - 1];
|
||||||
if (asDateOnlyString(lastPoint.date) === asDateOnlyString(currentDay)) {
|
if (asDateOnlyString(lastPoint.date) === asDateOnlyString(currentDay)) {
|
||||||
@@ -596,11 +630,11 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
|
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT user_id
|
SELECT user_id
|
||||||
FROM workspace_user_permissions
|
FROM workspace_members
|
||||||
WHERE workspace_id = wp.workspace_id
|
WHERE workspace_id = wp.workspace_id
|
||||||
AND type = ${WorkspaceRole.Owner}
|
AND role = 'owner'
|
||||||
AND status = 'Accepted'::"WorkspaceMemberStatus"
|
AND state = 'active'
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) owner ON TRUE
|
) owner ON TRUE
|
||||||
WHERE wp.public = TRUE
|
WHERE wp.public = TRUE
|
||||||
@@ -861,10 +895,10 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
mla.last_doc_id AS "lastDocId"
|
mla.last_doc_id AS "lastDocId"
|
||||||
FROM workspace_member_last_access mla
|
FROM workspace_member_last_access mla
|
||||||
INNER JOIN users u ON u.id = mla.user_id
|
INNER JOIN users u ON u.id = mla.user_id
|
||||||
INNER JOIN workspace_user_permissions wur
|
INNER JOIN workspace_members wm
|
||||||
ON wur.workspace_id = mla.workspace_id
|
ON wm.workspace_id = mla.workspace_id
|
||||||
AND wur.user_id = mla.user_id
|
AND wm.user_id = mla.user_id
|
||||||
AND wur.status = 'Accepted'::"WorkspaceMemberStatus"
|
AND wm.state = 'active'
|
||||||
WHERE mla.workspace_id = ${input.workspaceId}
|
WHERE mla.workspace_id = ${input.workspaceId}
|
||||||
AND mla.last_doc_id = ${input.docId}
|
AND mla.last_doc_id = ${input.docId}
|
||||||
${windowCondition}
|
${windowCondition}
|
||||||
@@ -1075,10 +1109,10 @@ export class WorkspaceAnalyticsModel extends BaseModel {
|
|||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
FROM workspace_member_last_access mla
|
FROM workspace_member_last_access mla
|
||||||
INNER JOIN users u ON u.id = mla.user_id
|
INNER JOIN users u ON u.id = mla.user_id
|
||||||
INNER JOIN workspace_user_permissions wur
|
INNER JOIN workspace_members wm
|
||||||
ON wur.workspace_id = mla.workspace_id
|
ON wm.workspace_id = mla.workspace_id
|
||||||
AND wur.user_id = mla.user_id
|
AND wm.user_id = mla.user_id
|
||||||
AND wur.status = 'Accepted'::"WorkspaceMemberStatus"
|
AND wm.state = 'active'
|
||||||
WHERE mla.workspace_id = ${workspaceId}
|
WHERE mla.workspace_id = ${workspaceId}
|
||||||
AND mla.last_doc_id = ${docId}
|
AND mla.last_doc_id = ${docId}
|
||||||
${windowCondition}
|
${windowCondition}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Transactional } from '@nestjs-cls/transactional';
|
import { Transactional } from '@nestjs-cls/transactional';
|
||||||
import {
|
import {
|
||||||
|
Prisma,
|
||||||
WorkspaceMemberSource,
|
WorkspaceMemberSource,
|
||||||
WorkspaceMemberStatus,
|
WorkspaceMemberStatus,
|
||||||
WorkspaceUserRole,
|
WorkspaceUserRole,
|
||||||
@@ -23,6 +24,15 @@ import {
|
|||||||
|
|
||||||
export { WorkspaceMemberStatus };
|
export { WorkspaceMemberStatus };
|
||||||
|
|
||||||
|
export type AdminWorkspaceMember = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
role: 'Owner' | 'Admin' | 'Collaborator';
|
||||||
|
status: WorkspaceMemberStatus;
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Events {
|
interface Events {
|
||||||
'workspace.owner.changed': {
|
'workspace.owner.changed': {
|
||||||
@@ -467,6 +477,66 @@ export class WorkspaceUserModel extends BaseModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async adminListMembers(
|
||||||
|
workspaceId: string,
|
||||||
|
options: {
|
||||||
|
first: number;
|
||||||
|
offset: number;
|
||||||
|
query?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<AdminWorkspaceMember[]> {
|
||||||
|
const keyword = options.query?.trim();
|
||||||
|
const keywordCondition = keyword
|
||||||
|
? Prisma.sql`AND (u.name ILIKE ${`%${keyword}%`} OR u.email ILIKE ${`%${keyword}%`})`
|
||||||
|
: Prisma.empty;
|
||||||
|
|
||||||
|
return await this.db.$queryRaw<AdminWorkspaceMember[]>`
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.avatar_url AS "avatarUrl",
|
||||||
|
CASE wm.role
|
||||||
|
WHEN 'owner' THEN 'Owner'
|
||||||
|
WHEN 'admin' THEN 'Admin'
|
||||||
|
ELSE 'Collaborator'
|
||||||
|
END AS role,
|
||||||
|
'Accepted'::"WorkspaceMemberStatus" AS status,
|
||||||
|
wm.created_at AS created_at
|
||||||
|
FROM workspace_members wm
|
||||||
|
INNER JOIN users u ON u.id = wm.user_id
|
||||||
|
WHERE wm.workspace_id = ${workspaceId}
|
||||||
|
AND wm.state = 'active'
|
||||||
|
${keywordCondition}
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u.avatar_url AS "avatarUrl",
|
||||||
|
CASE wi.requested_role
|
||||||
|
WHEN 'admin' THEN 'Admin'
|
||||||
|
ELSE 'Collaborator'
|
||||||
|
END AS role,
|
||||||
|
CASE wi.status
|
||||||
|
WHEN 'waiting_review' THEN 'UnderReview'::"WorkspaceMemberStatus"
|
||||||
|
WHEN 'waiting_seat' THEN 'NeedMoreSeat'::"WorkspaceMemberStatus"
|
||||||
|
ELSE 'Pending'::"WorkspaceMemberStatus"
|
||||||
|
END AS status,
|
||||||
|
wi.created_at AS created_at
|
||||||
|
FROM workspace_invitations wi
|
||||||
|
INNER JOIN users u ON u.id = wi.invitee_user_id
|
||||||
|
WHERE wi.workspace_id = ${workspaceId}
|
||||||
|
${keywordCondition}
|
||||||
|
) members
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
OFFSET ${options.offset}
|
||||||
|
LIMIT ${options.first}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional()
|
@Transactional()
|
||||||
async allocateSeats(workspaceId: string, limit: number) {
|
async allocateSeats(workspaceId: string, limit: number) {
|
||||||
return await allocateWorkspaceSeats(
|
return await allocateWorkspaceSeats(
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Transactional } from '@nestjs-cls/transactional';
|
import { Transactional } from '@nestjs-cls/transactional';
|
||||||
import { Prisma, type Workspace, WorkspaceMemberStatus } from '@prisma/client';
|
import { Prisma, type Workspace } from '@prisma/client';
|
||||||
|
|
||||||
import { EventBus } from '../base';
|
import { EventBus } from '../base';
|
||||||
import { BaseModel } from './base';
|
import { BaseModel } from './base';
|
||||||
import type { WorkspaceFeatureName } from './common';
|
import type { WorkspaceFeatureName } from './common';
|
||||||
import { WorkspaceRole } from './common/role';
|
|
||||||
|
|
||||||
type RawWorkspaceSummary = {
|
type RawWorkspaceSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -292,6 +291,82 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
${featuresHaving}
|
${featuresHaving}
|
||||||
`
|
`
|
||||||
: Prisma.empty;
|
: Prisma.empty;
|
||||||
|
const workspaceOnlyGroupAndHaving =
|
||||||
|
features.length > 0
|
||||||
|
? Prisma.sql`
|
||||||
|
GROUP BY w.id,
|
||||||
|
w.public,
|
||||||
|
w.created_at,
|
||||||
|
w.name,
|
||||||
|
w.avatar_key,
|
||||||
|
w.enable_ai,
|
||||||
|
w.enable_sharing,
|
||||||
|
w.enable_url_preview,
|
||||||
|
w.enable_doc_embedding
|
||||||
|
${featuresHaving}
|
||||||
|
`
|
||||||
|
: Prisma.empty;
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||||
|
WITH filtered AS (
|
||||||
|
SELECT w.id,
|
||||||
|
w.public,
|
||||||
|
w.created_at AS "createdAt",
|
||||||
|
w.name,
|
||||||
|
w.avatar_key AS "avatarKey",
|
||||||
|
w.enable_ai AS "enableAi",
|
||||||
|
w.enable_sharing AS "enableSharing",
|
||||||
|
w.enable_url_preview AS "enableUrlPreview",
|
||||||
|
w.enable_doc_embedding AS "enableDocEmbedding"
|
||||||
|
FROM workspaces w
|
||||||
|
${featureJoin}
|
||||||
|
WHERE ${
|
||||||
|
this.buildAdminFlagWhere(flags).length
|
||||||
|
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
|
||||||
|
: Prisma.sql`TRUE`
|
||||||
|
}
|
||||||
|
${workspaceOnlyGroupAndHaving}
|
||||||
|
),
|
||||||
|
page AS (
|
||||||
|
SELECT f.*,
|
||||||
|
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
|
||||||
|
COALESCE(s.snapshot_size, 0) AS "snapshotSize",
|
||||||
|
COALESCE(s.blob_count, 0) AS "blobCount",
|
||||||
|
COALESCE(s.blob_size, 0) AS "blobSize",
|
||||||
|
COALESCE(s.member_count, 0) AS "memberCount",
|
||||||
|
COALESCE(s.public_page_count, 0) AS "publicPageCount",
|
||||||
|
COALESCE(s.features, ARRAY[]::text[]) AS features
|
||||||
|
FROM filtered f
|
||||||
|
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
|
||||||
|
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
||||||
|
LIMIT ${options.first}
|
||||||
|
OFFSET ${options.skip}
|
||||||
|
)
|
||||||
|
SELECT p.*,
|
||||||
|
o.owner_id AS "ownerId",
|
||||||
|
o.owner_name AS "ownerName",
|
||||||
|
o.owner_email AS "ownerEmail",
|
||||||
|
o.owner_avatar_url AS "ownerAvatarUrl"
|
||||||
|
FROM page p
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.id AS owner_id,
|
||||||
|
u.name AS owner_name,
|
||||||
|
u.email AS owner_email,
|
||||||
|
u.avatar_url AS owner_avatar_url
|
||||||
|
FROM workspace_members AS wm
|
||||||
|
JOIN users u ON wm.user_id = u.id
|
||||||
|
WHERE wm.workspace_id = p.id
|
||||||
|
AND wm.role = 'owner'
|
||||||
|
AND wm.state = 'active'
|
||||||
|
ORDER BY u.created_at ASC, wm.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) o ON TRUE
|
||||||
|
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { rows: this.mapAdminWorkspaceRows(rows), total };
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||||
WITH filtered AS (
|
WITH filtered AS (
|
||||||
@@ -314,12 +389,12 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
u.name AS owner_name,
|
u.name AS owner_name,
|
||||||
u.email AS owner_email,
|
u.email AS owner_email,
|
||||||
u.avatar_url AS owner_avatar_url
|
u.avatar_url AS owner_avatar_url
|
||||||
FROM workspace_user_permissions AS wur
|
FROM workspace_members AS wm
|
||||||
JOIN users u ON wur.user_id = u.id
|
JOIN users u ON wm.user_id = u.id
|
||||||
WHERE wur.workspace_id = w.id
|
WHERE wm.workspace_id = w.id
|
||||||
AND wur.type = ${WorkspaceRole.Owner}
|
AND wm.role = 'owner'
|
||||||
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
AND wm.state = 'active'
|
||||||
ORDER BY u.created_at ASC
|
ORDER BY u.created_at ASC, wm.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) o ON TRUE
|
) o ON TRUE
|
||||||
${featureJoin}
|
${featureJoin}
|
||||||
@@ -359,7 +434,11 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
OFFSET ${options.skip}
|
OFFSET ${options.skip}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const mapped = rows.map(row => ({
|
return { rows: this.mapAdminWorkspaceRows(rows), total };
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapAdminWorkspaceRows(rows: RawWorkspaceSummary[]) {
|
||||||
|
return rows.map(row => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
public: row.public,
|
public: row.public,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
@@ -385,8 +464,50 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return { rows: mapped, total };
|
async adminGetWorkspace(id: string) {
|
||||||
|
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||||
|
SELECT w.id,
|
||||||
|
w.public,
|
||||||
|
w.created_at AS "createdAt",
|
||||||
|
w.name,
|
||||||
|
w.avatar_key AS "avatarKey",
|
||||||
|
w.enable_ai AS "enableAi",
|
||||||
|
w.enable_sharing AS "enableSharing",
|
||||||
|
w.enable_url_preview AS "enableUrlPreview",
|
||||||
|
w.enable_doc_embedding AS "enableDocEmbedding",
|
||||||
|
o.owner_id AS "ownerId",
|
||||||
|
o.owner_name AS "ownerName",
|
||||||
|
o.owner_email AS "ownerEmail",
|
||||||
|
o.owner_avatar_url AS "ownerAvatarUrl",
|
||||||
|
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
|
||||||
|
COALESCE(s.snapshot_size, 0) AS "snapshotSize",
|
||||||
|
COALESCE(s.blob_count, 0) AS "blobCount",
|
||||||
|
COALESCE(s.blob_size, 0) AS "blobSize",
|
||||||
|
COALESCE(s.member_count, 0) AS "memberCount",
|
||||||
|
COALESCE(s.public_page_count, 0) AS "publicPageCount",
|
||||||
|
COALESCE(s.features, ARRAY[]::text[]) AS features
|
||||||
|
FROM workspaces w
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT u.id AS owner_id,
|
||||||
|
u.name AS owner_name,
|
||||||
|
u.email AS owner_email,
|
||||||
|
u.avatar_url AS owner_avatar_url
|
||||||
|
FROM workspace_members AS wm
|
||||||
|
JOIN users u ON wm.user_id = u.id
|
||||||
|
WHERE wm.workspace_id = w.id
|
||||||
|
AND wm.role = 'owner'
|
||||||
|
AND wm.state = 'active'
|
||||||
|
ORDER BY u.created_at ASC, wm.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) o ON TRUE
|
||||||
|
LEFT JOIN workspace_admin_stats s ON s.workspace_id = w.id
|
||||||
|
WHERE w.id = ${id}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
return this.mapAdminWorkspaceRows(rows)[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async adminCountWorkspaces(options: {
|
async adminCountWorkspaces(options: {
|
||||||
@@ -404,6 +525,27 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
const features = options.features ?? [];
|
const features = options.features ?? [];
|
||||||
const flags = options.flags ?? {};
|
const flags = options.flags ?? {};
|
||||||
|
|
||||||
|
if (!keyword && features.length === 0) {
|
||||||
|
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM workspaces w
|
||||||
|
WHERE ${
|
||||||
|
this.buildAdminFlagWhere(flags).length
|
||||||
|
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
|
||||||
|
: Prisma.sql`TRUE`
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return row?.total ? Number(row.total) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureJoin =
|
||||||
|
features.length > 0
|
||||||
|
? Prisma.sql`
|
||||||
|
LEFT JOIN workspace_features wf
|
||||||
|
ON wf.workspace_id = w.id AND wf.activated = TRUE
|
||||||
|
`
|
||||||
|
: Prisma.empty;
|
||||||
const featuresHaving =
|
const featuresHaving =
|
||||||
features.length > 0
|
features.length > 0
|
||||||
? Prisma.sql`
|
? Prisma.sql`
|
||||||
@@ -415,13 +557,25 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
`
|
`
|
||||||
: Prisma.empty;
|
: Prisma.empty;
|
||||||
|
|
||||||
const featureJoin =
|
if (!keyword) {
|
||||||
features.length > 0
|
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
||||||
? Prisma.sql`
|
WITH filtered AS (
|
||||||
LEFT JOIN workspace_features wf
|
SELECT w.id
|
||||||
ON wf.workspace_id = w.id AND wf.activated = TRUE
|
FROM workspaces w
|
||||||
`
|
${featureJoin}
|
||||||
: Prisma.empty;
|
WHERE ${
|
||||||
|
this.buildAdminFlagWhere(flags).length
|
||||||
|
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
|
||||||
|
: Prisma.sql`TRUE`
|
||||||
|
}
|
||||||
|
GROUP BY w.id
|
||||||
|
${featuresHaving}
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) AS total FROM filtered
|
||||||
|
`;
|
||||||
|
|
||||||
|
return row?.total ? Number(row.total) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
const groupAndHaving =
|
const groupAndHaving =
|
||||||
features.length > 0
|
features.length > 0
|
||||||
@@ -438,15 +592,15 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
o.owner_email AS "ownerEmail"
|
o.owner_email AS "ownerEmail"
|
||||||
FROM workspaces w
|
FROM workspaces w
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT wur.workspace_id,
|
SELECT wm.workspace_id,
|
||||||
u.id AS owner_id,
|
u.id AS owner_id,
|
||||||
u.email AS owner_email
|
u.email AS owner_email
|
||||||
FROM workspace_user_permissions AS wur
|
FROM workspace_members AS wm
|
||||||
JOIN users u ON wur.user_id = u.id
|
JOIN users u ON wm.user_id = u.id
|
||||||
WHERE wur.workspace_id = w.id
|
WHERE wm.workspace_id = w.id
|
||||||
AND wur.type = ${WorkspaceRole.Owner}
|
AND wm.role = 'owner'
|
||||||
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
AND wm.state = 'active'
|
||||||
ORDER BY u.created_at ASC
|
ORDER BY u.created_at ASC, wm.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) o ON TRUE
|
) o ON TRUE
|
||||||
${featureJoin}
|
${featureJoin}
|
||||||
@@ -519,20 +673,20 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
) {
|
) {
|
||||||
switch (order) {
|
switch (order) {
|
||||||
case 'snapshotSize':
|
case 'snapshotSize':
|
||||||
return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC`;
|
return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
|
||||||
case 'blobCount':
|
case 'blobCount':
|
||||||
return `"blobCount" DESC NULLS LAST, "createdAt" DESC`;
|
return `"blobCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
|
||||||
case 'blobSize':
|
case 'blobSize':
|
||||||
return `"blobSize" DESC NULLS LAST, "createdAt" DESC`;
|
return `"blobSize" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
|
||||||
case 'snapshotCount':
|
case 'snapshotCount':
|
||||||
return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC`;
|
return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
|
||||||
case 'memberCount':
|
case 'memberCount':
|
||||||
return `"memberCount" DESC NULLS LAST, "createdAt" DESC`;
|
return `"memberCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
|
||||||
case 'publicPageCount':
|
case 'publicPageCount':
|
||||||
return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC`;
|
return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
|
||||||
case 'createdAt':
|
case 'createdAt':
|
||||||
default:
|
default:
|
||||||
return `"createdAt" DESC`;
|
return `"createdAt" DESC, "id" ASC`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|||||||
@@ -163,10 +163,16 @@ type AdminWorkspaceMember {
|
|||||||
email: String!
|
email: String!
|
||||||
id: String!
|
id: String!
|
||||||
name: String!
|
name: String!
|
||||||
role: Permission!
|
role: AdminWorkspaceMemberRole!
|
||||||
status: WorkspaceMemberStatus!
|
status: WorkspaceMemberStatus!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AdminWorkspaceMemberRole {
|
||||||
|
Admin
|
||||||
|
Collaborator
|
||||||
|
Owner
|
||||||
|
}
|
||||||
|
|
||||||
type AdminWorkspaceSharedLink {
|
type AdminWorkspaceSharedLink {
|
||||||
docId: String!
|
docId: String!
|
||||||
publishedAt: DateTime
|
publishedAt: DateTime
|
||||||
|
|||||||
@@ -213,10 +213,16 @@ export interface AdminWorkspaceMember {
|
|||||||
email: Scalars['String']['output'];
|
email: Scalars['String']['output'];
|
||||||
id: Scalars['String']['output'];
|
id: Scalars['String']['output'];
|
||||||
name: Scalars['String']['output'];
|
name: Scalars['String']['output'];
|
||||||
role: Permission;
|
role: AdminWorkspaceMemberRole;
|
||||||
status: WorkspaceMemberStatus;
|
status: WorkspaceMemberStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AdminWorkspaceMemberRole {
|
||||||
|
Admin = 'Admin',
|
||||||
|
Collaborator = 'Collaborator',
|
||||||
|
Owner = 'Owner',
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminWorkspaceSharedLink {
|
export interface AdminWorkspaceSharedLink {
|
||||||
__typename?: 'AdminWorkspaceSharedLink';
|
__typename?: 'AdminWorkspaceSharedLink';
|
||||||
docId: Scalars['String']['output'];
|
docId: Scalars['String']['output'];
|
||||||
@@ -4013,7 +4019,7 @@ export type AdminWorkspaceQuery = {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
role: Permission;
|
role: AdminWorkspaceMemberRole;
|
||||||
status: WorkspaceMemberStatus;
|
status: WorkspaceMemberStatus;
|
||||||
}>;
|
}>;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
@@ -102,14 +102,14 @@ export const useColumns = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'members',
|
accessorKey: 'members',
|
||||||
header: () => <div className="text-xs font-medium">Members</div>,
|
header: () => <div className="text-xs font-medium">Active Members</div>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const ws = row.original;
|
const ws = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col text-xs gap-1">
|
<div className="flex flex-col text-xs gap-1">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="font-medium">{ws.memberCount}</span>
|
<span className="font-medium">{ws.memberCount}</span>
|
||||||
<span className="text-muted-foreground">members</span>
|
<span className="text-muted-foreground">active members</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<span className="font-medium">{ws.publicPageCount}</span>
|
<span className="font-medium">{ws.publicPageCount}</span>
|
||||||
|
|||||||
@@ -235,7 +235,10 @@ function WorkspacePanelContent({
|
|||||||
value={formatBytes(workspace.blobSize)}
|
value={formatBytes(workspace.blobSize)}
|
||||||
/>
|
/>
|
||||||
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
|
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
|
||||||
<MetricCard label="Members" value={`${workspace.memberCount}`} />
|
<MetricCard
|
||||||
|
label="Active Members"
|
||||||
|
value={`${workspace.memberCount}`}
|
||||||
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Shared Pages"
|
label="Shared Pages"
|
||||||
value={`${workspace.publicPageCount}`}
|
value={`${workspace.publicPageCount}`}
|
||||||
@@ -243,12 +246,14 @@ function WorkspacePanelContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border/60 bg-card shadow-sm">
|
<div className="rounded-xl border border-border/60 bg-card shadow-sm">
|
||||||
<div className="px-3 py-2 text-sm font-medium">Members</div>
|
<div className="px-3 py-2 text-sm font-medium">
|
||||||
|
Members and Invitations
|
||||||
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-col divide-y">
|
<div className="flex flex-col divide-y">
|
||||||
{memberList.length === 0 ? (
|
{memberList.length === 0 ? (
|
||||||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||||
No members.
|
No members or invitations.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
memberList.map(member => (
|
memberList.map(member => (
|
||||||
@@ -270,8 +275,15 @@ function WorkspacePanelContent({
|
|||||||
{member.email}
|
{member.email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto text-xs px-2 py-1 rounded border">
|
<div className="ml-auto flex flex-col items-end gap-1 text-xs">
|
||||||
{member.role}
|
<div className="rounded border px-2 py-1">
|
||||||
|
{member.role}
|
||||||
|
</div>
|
||||||
|
{member.status !== 'Accepted' ? (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{member.status}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user