fix(workspace): show member emails (#2393)

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-08-04 11:48:43 +08:00
committed by GitHub
parent e263a5d1d7
commit 7820949d3a
8 changed files with 189 additions and 12 deletions
@@ -30,12 +30,14 @@ def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
def _membership_payload(
membership: WorkspaceMembership,
*,
display_name: str,
email: str,
) -> dict[str, typing.Any]:
return {
'uuid': membership.uuid,
'workspace_uuid': membership.workspace_uuid,
'account_uuid': membership.account_uuid,
'display_name': display_name,
'email': email,
'role': membership.role,
'status': membership.status,
@@ -94,7 +96,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
workspaces.append(
{
'workspace': _workspace_payload(access.workspace),
'membership': _membership_payload(access.membership, email=account.user),
'membership': _membership_payload(
access.membership,
display_name=account.user,
email=account.normalized_email,
),
'permissions': sorted(permissions_for_role(access.membership.role)),
'placement_generation': access.execution.placement_generation,
'plan_name': plan_name,
@@ -137,6 +143,7 @@ class WorkspacesRouterGroup(group.RouterGroup):
'uuid': None,
'workspace_uuid': request_context.workspace_uuid,
'account_uuid': None,
'display_name': None,
'email': None,
'role': 'owner',
'status': 'active',
@@ -154,7 +161,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
return self.success(
data={
'workspace': _workspace_payload(workspace),
'membership': _membership_payload(membership, email=account.user),
'membership': _membership_payload(
membership,
display_name=account.user,
email=account.normalized_email,
),
'permissions': sorted(request_context.workspace.permissions),
'placement_generation': request_context.placement_generation,
'plan_name': plan_name,
@@ -283,7 +294,8 @@ class WorkspacesRouterGroup(group.RouterGroup):
data={
'member': _membership_payload(
member,
email=account.user if account is not None else '',
display_name=account.user if account is not None else '',
email=account.normalized_email if account is not None else '',
)
}
)
@@ -302,7 +314,11 @@ class WorkspacesRouterGroup(group.RouterGroup):
@staticmethod
def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]:
return _membership_payload(view.membership, email=view.email)
return _membership_payload(
view.membership,
display_name=view.display_name,
email=view.email,
)
@group.group_class('invitations', '/api/v1/invitations')
+8 -3
View File
@@ -88,6 +88,7 @@ class ResolvedWorkspaceAccess:
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceMemberView:
membership: WorkspaceMembership
display_name: str
email: str
@@ -294,7 +295,7 @@ class WorkspaceCollaborationService:
async def operation(active_session: AsyncSession) -> list[WorkspaceMemberView]:
await self._load_actor(active_session, workspace_uuid, actor)
statement = (
sqlalchemy.select(WorkspaceMembership, User.user)
sqlalchemy.select(WorkspaceMembership, User.user, User.normalized_email)
.join(User, User.uuid == WorkspaceMembership.account_uuid)
.where(
WorkspaceMembership.workspace_uuid == workspace_uuid,
@@ -304,8 +305,12 @@ class WorkspaceCollaborationService:
.order_by(WorkspaceMembership.created_at, WorkspaceMembership.uuid)
)
return [
WorkspaceMemberView(membership=membership, email=email)
for membership, email in (await active_session.execute(statement)).all()
WorkspaceMemberView(
membership=membership,
display_name=display_name,
email=email,
)
for membership, display_name, email in (await active_session.execute(statement)).all()
]
return await self._run(operation, session=session, read_only=True)
+25
View File
@@ -281,6 +281,31 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
async def test_workspace_member_list_returns_display_name_and_email(workspace_api):
_, client, engine, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
current = (await current_response.get_json())['data']
workspace_uuid = current['workspace']['uuid']
owner_uuid = current['membership']['account_uuid']
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.update(User).where(User.uuid == owner_uuid).values(user='Owner Display Name')
)
response = await client.get(
f'/api/v1/workspaces/{workspace_uuid}/members',
headers=_auth(owner_token, workspace_uuid),
)
assert response.status_code == 200
members = (await response.get_json())['data']['members']
assert len(members) == 1
assert members[0]['display_name'] == 'Owner Display Name'
assert members[0]['email'] == 'owner@example.com'
async def test_oss_invitation_accept_requires_logout_before_registration(workspace_api):
_, client, _, owner_token = workspace_api
@@ -313,18 +313,20 @@ export default function WorkspaceSettingsPanel({
<ItemMedia variant="icon">
<Users className="size-4" />
</ItemMedia>
<ItemContent>
<ItemContent className="min-w-0">
<ItemTitle>
{member.email}
{member.display_name}
{isSelf && (
<Badge variant="outline">{t('workspace.you')}</Badge>
)}
</ItemTitle>
<ItemDescription>
{t(`workspace.roles.${member.role}`)}
<ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<span className="break-all">{member.email}</span>
<span aria-hidden="true">·</span>
<span>{t(`workspace.roles.${member.role}`)}</span>
</ItemDescription>
</ItemContent>
<ItemActions>
<ItemActions className="max-sm:basis-full max-sm:justify-end max-sm:pl-10">
{canUpdateMembers && member.role !== 'owner' && (
<Select
value={member.role}
+1
View File
@@ -19,6 +19,7 @@ export interface WorkspaceMembership {
uuid: string;
workspace_uuid: string;
account_uuid: string;
display_name: string;
email: string;
role: WorkspaceRole;
status: 'active' | 'disabled' | 'removed';
+2
View File
@@ -81,6 +81,7 @@ export interface WorkspaceEntryMock {
uuid: string;
workspace_uuid: string;
account_uuid: string;
display_name: string;
email: string;
role: 'owner' | 'admin' | 'developer' | 'operator' | 'viewer';
status: 'active';
@@ -155,6 +156,7 @@ export function makeWorkspaceEntry(
uuid: `membership-${uuid}`,
workspace_uuid: uuid,
account_uuid: 'account-playwright',
display_name: 'Playwright Admin',
email: 'admin@example.com',
role: 'owner',
status: 'active',
@@ -0,0 +1,102 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
const members = [
{
uuid: 'membership-owner',
workspace_uuid: 'workspace-email-test',
account_uuid: 'account-playwright',
display_name: 'RockChinQ',
email: 'rock@example.com',
role: 'owner',
status: 'active',
joined_at: '2026-08-01T00:00:00Z',
created_at: '2026-08-01T00:00:00Z',
},
{
uuid: 'membership-admin',
workspace_uuid: 'workspace-email-test',
account_uuid: 'account-admin',
display_name: 'Junyan Qin',
email: 'a.very.long.workspace.member.email.address@example-company.test',
role: 'admin',
status: 'active',
joined_at: '2026-08-02T00:00:00Z',
created_at: '2026-08-02T00:00:00Z',
},
];
test.use({ viewport: { width: 390, height: 844 } });
test('workspace member list displays each member email without overlapping controls', async ({
page,
}, testInfo) => {
const workspace = makeWorkspaceEntry(
'workspace-email-test',
'Email Test Workspace',
'cloud_projection',
);
workspace.membership.display_name = 'RockChinQ';
workspace.membership.email = 'rock@example.com';
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.route(
'**/api/v1/workspaces/workspace-email-test/members',
(route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 0, message: 'ok', data: { members } }),
}),
);
await page.route(
'**/api/v1/workspaces/workspace-email-test/invitations',
(route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'ok',
data: { invitations: [] },
}),
}),
);
await page.goto('/home?action=showWorkspaceSettings');
await expect(page.getByText('RockChinQ')).toBeVisible();
await expect(page.getByText('rock@example.com')).toBeVisible();
await expect(page.getByText('Junyan Qin')).toBeVisible();
const longEmail = page.getByText(
'a.very.long.workspace.member.email.address@example-company.test',
);
await expect(longEmail).toBeVisible();
await longEmail.scrollIntoViewIfNeeded();
const roleSelect = page.getByRole('combobox').last();
const [emailBox, selectBox] = await Promise.all([
longEmail.boundingBox(),
roleSelect.boundingBox(),
]);
expect(emailBox).not.toBeNull();
expect(selectBox).not.toBeNull();
const overlaps =
emailBox!.x < selectBox!.x + selectBox!.width &&
emailBox!.x + emailBox!.width > selectBox!.x &&
emailBox!.y < selectBox!.y + selectBox!.height &&
emailBox!.y + emailBox!.height > selectBox!.y;
expect(overlaps).toBe(false);
expect(selectBox!.y).toBeGreaterThanOrEqual(emailBox!.y + emailBox!.height);
await roleSelect.scrollIntoViewIfNeeded();
await page.screenshot({
path: testInfo.outputPath('workspace-member-emails-mobile.png'),
fullPage: true,
});
});
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const panelSource = await readFile(
new URL(
'../../src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
import.meta.url,
),
'utf8',
);
const entitySource = await readFile(
new URL('../../src/app/infra/entities/workspace.ts', import.meta.url),
'utf8',
);
test('workspace member rows show display name, email, and role', () => {
assert.match(entitySource, /display_name:\s*string/);
assert.match(panelSource, /\{member\.display_name\}/);
assert.match(
panelSource,
/<ItemDescription[^>]*>[\s\S]*?\{member\.email\}[\s\S]*?workspace\.roles\.\$\{member\.role\}[\s\S]*?<\/ItemDescription>/,
);
});