mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-08 04:10:59 +00:00
fix(workspace): show member emails (#2393)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user