Skip to content

Commit 2db4ab9

Browse files
bjaggclaude
andauthored
Issue #943: Friendly display_name on workspaces (initial stab) (#947)
## Summary Adds a `display_name` field to the workspace listing surface so the SPA picker shows a human-friendly label instead of `eval-<cognito-sub>` for a user's own personal tenant. Resolves the most-visible piece of #943. For each workspace: | Workspace | `display_name` | |---|---| | `eval-<their-cognito-sub>` (the caller's own personal tenant — verified by matching JWT `sub` against the group suffix) | their email (e.g. `user@example.edu`) | | `lif-team` and any other shared / invited group | the group name itself | | Anything else (legacy HS256 callers whose principal is a raw sub, missing context, etc.) | the group name (fallback) | PM confirmed on the 2026-05-26 call that the `@` character is fine in the UI. ## Surface area | Layer | Change | |---|---| | **`workspace_service.py`** | `WorkspaceItem` gains a required `display_name: str`. New `compute_display_name(group, cognito_sub, principal)`. `to_workspace_item()` now takes optional identity kwargs. | | **`tenant_endpoints.py`** | `/tenants/mine` + `/tenants/select` thread `cognito_sub` + `principal` from `request.state` through to the projection. `SelectWorkspaceResponse` also carries `display_name` so the SPA can update its mirror immediately on select. | | **`tenantsService.ts`** | `WorkspaceItem` + `SelectWorkspaceResponse` get `display_name?: string` (optional on the wire so the frontend can deploy ahead of the backend; falls back to `group`). | | **`Workspaces.tsx`** | Picker card heading shows `display_name ?? group`. Schema name stays as the secondary line per PM ask: *"keep the display of actual schema under workspace name."* | | **Tests** | New `compute_display_name` + `to_workspace_item` coverage; `/tenants/mine` + `/tenants/select` tests updated; new test for the eval-<sub> → email replacement. | ## Verification - [x] `uv run pytest test/components/lif/mdr_services/test_workspace_service.py test/bases/lif/mdr_restapi/test_tenant_endpoints.py` — **56 passed** - [x] `uv run ruff check` + `uv run ruff format` — clean - [x] `uv run ty check` (touched files) — clean - [x] `npx tsc --noEmit -p tsconfig.app.json` — clean - [x] `npx vite build` — 499 modules, no errors ## Out of scope here (follow-ups) - **Header badge** (PR #944) still shows `group`. After both PRs merge it's a 1-line change to switch to `display_name` — easier as a tiny follow-up than a merge conflict here. - **Admin-controlled per-group display labels** (e.g. rename `lif-team` to "LIF Internal"). Future work; not requested for the demo. Auto-deploys to dev frontend on merge via `lif_mdr_frontend.yml`. Backend deploys via the standard `aws-deploy.sh -s dev --only-stack dev-lif-mdr-api` path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8361c7a commit 2db4ab9

6 files changed

Lines changed: 263 additions & 15 deletions

File tree

bases/lif/mdr_restapi/tenant_endpoints.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ class SelectWorkspaceRequest(BaseModel):
8787
class SelectWorkspaceResponse(BaseModel):
8888
group: str
8989
tenant_schema: str
90+
# display_name mirrors the friendly label from /tenants/mine so the
91+
# frontend can update its workspace indicator immediately on select
92+
# without a follow-up listing round-trip (issue #943).
93+
display_name: str
9094

9195

9296
class CreateInviteRequest(BaseModel):
@@ -231,8 +235,12 @@ async def list_my_workspaces(
231235
receive an empty list.
232236
"""
233237
cognito_groups: list[str] = getattr(request.state, "cognito_groups", [])
238+
cognito_sub: str | None = getattr(request.state, "cognito_sub", None)
239+
principal: str | None = getattr(request.state, "principal", None)
234240
workspaces = list_workspaces_for_groups(cognito_groups)
235-
return ListMyWorkspacesResponse(workspaces=[to_workspace_item(w) for w in workspaces])
241+
return ListMyWorkspacesResponse(
242+
workspaces=[to_workspace_item(w, cognito_sub=cognito_sub, principal=principal) for w in workspaces]
243+
)
236244

237245

238246
@router.post(
@@ -285,7 +293,10 @@ async def select_workspace(
285293
path="/",
286294
)
287295
logger.info("User %r selected workspace %r → %s", request.state.principal, workspace.group, workspace.tenant_schema)
288-
return SelectWorkspaceResponse(group=workspace.group, tenant_schema=workspace.tenant_schema)
296+
cognito_sub: str | None = getattr(request.state, "cognito_sub", None)
297+
principal: str | None = getattr(request.state, "principal", None)
298+
item = to_workspace_item(workspace, cognito_sub=cognito_sub, principal=principal)
299+
return SelectWorkspaceResponse(group=item.group, tenant_schema=item.tenant_schema, display_name=item.display_name)
289300

290301

291302
@router.post(

components/lif/mdr_services/workspace_service.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,70 @@ class WorkspaceItem(BaseModel):
3131
"""API response shape for a workspace.
3232
3333
Separate from the internal ``Workspace`` dataclass so the wire
34-
contract can evolve independently of in-process types — e.g., we
35-
might later add ``display_name`` or ``schema_status`` to the API
36-
without changing the service-layer return shape.
34+
contract can evolve independently of in-process types.
35+
36+
``display_name`` is the human-friendly label the SPA shows in the
37+
workspace picker + header indicator (issue #943). For a user's own
38+
auto-created personal tenant (``eval-<their-cognito-sub>``), this is
39+
their email — meaningful to the user and consistent with how the
40+
rest of the LIF stack identifies them. For shared / invited groups
41+
(``lif-team`` and future named teams), it's the group name itself.
42+
The technical ``tenant_schema`` stays on every record so the picker
43+
can still surface it as secondary text.
3744
"""
3845

3946
group: str
4047
tenant_schema: str
41-
42-
43-
def to_workspace_item(workspace: Workspace) -> WorkspaceItem:
44-
"""Project a service-layer ``Workspace`` into its API response shape."""
45-
return WorkspaceItem(group=workspace.group, tenant_schema=workspace.tenant_schema)
48+
display_name: str
49+
50+
51+
def compute_display_name(group: str, cognito_sub: str | None, principal: str | None, tenant_schema: str) -> str:
52+
"""Resolve a friendly display name for a Cognito group (issue #943).
53+
54+
For the user's own personal tenant (``eval-<their-sub>``), where we
55+
can confidently match the JWT's ``sub`` against the group's suffix,
56+
use the user's email — which the auth middleware stores as
57+
``request.state.principal`` for Cognito users with a verified
58+
email claim. The ``@`` is intentional; PM call on 2026-05-26
59+
explicitly confirmed it's fine in the UI.
60+
61+
For any group that isn't the caller's own ``eval-<sub>``, use the
62+
group name. That covers shared groups (``lif-team``, future named
63+
teams), invited memberships, and the edge case where ``principal``
64+
is a raw sub (legacy HS256 path, no email claim).
65+
66+
``tenant_schema`` is the ultimate fallback. The wire contract for
67+
``display_name`` is "never empty"; if both the personal and group
68+
paths somehow produced an empty/whitespace-only string (group is
69+
impossibly empty, principal is whitespace-only, etc.), the schema
70+
name is preferable to a blank label in the SPA. Per Adam Hungerford
71+
review of #947 — defense in depth on a server-side invariant the
72+
frontend now relies on.
73+
"""
74+
if cognito_sub is not None and principal is not None and "@" in principal and group == f"eval-{cognito_sub}":
75+
candidate = principal
76+
else:
77+
candidate = group
78+
candidate = candidate.strip()
79+
return candidate if candidate else tenant_schema
80+
81+
82+
def to_workspace_item(
83+
workspace: Workspace, *, cognito_sub: str | None = None, principal: str | None = None
84+
) -> WorkspaceItem:
85+
"""Project a service-layer ``Workspace`` into its API response shape.
86+
87+
Computes ``display_name`` from the optional caller-identity hints.
88+
Callers without identity context (tests that don't care about the
89+
friendly label) get ``display_name = group`` (or ``tenant_schema``
90+
if the group somehow sanitizes to empty), matching pre-#943
91+
behavior modulo the empty-string defense.
92+
"""
93+
return WorkspaceItem(
94+
group=workspace.group,
95+
tenant_schema=workspace.tenant_schema,
96+
display_name=compute_display_name(workspace.group, cognito_sub, principal, workspace.tenant_schema),
97+
)
4698

4799

48100
def list_workspaces_for_groups(cognito_groups: list[str] | None) -> list[Workspace]:

frontends/mdr-frontend/src/pages/Workspaces.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,17 @@ const Workspaces: React.FC = () => {
255255
<Card key={ws.group}>
256256
<Flex align="center" justify="between" gap="3">
257257
<Box>
258-
<Heading size="4">{ws.group}</Heading>
258+
{/* display_name is the friendly label (email for personal
259+
tenants, group name for shared). Backend guarantees
260+
it's present and non-empty — `compute_display_name`
261+
falls through to `tenant_schema` rather than ever
262+
returning an empty string. Using `||` not `??` so a
263+
corrupted runtime value (manual localStorage edit,
264+
future code path that writes an empty string) still
265+
falls back to `group` rather than rendering a blank
266+
heading — defense in depth per Adam Hungerford
267+
review of #947. */}
268+
<Heading size="4">{ws.display_name || ws.group}</Heading>
259269
<Text size="1" color="gray">
260270
Schema: {ws.tenant_schema}
261271
</Text>

frontends/mdr-frontend/src/services/tenantsService.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ import api from "./api";
55
export interface WorkspaceItem {
66
group: string;
77
tenant_schema: string;
8+
/**
9+
* Friendly human-readable label (issue #943). For a user's own auto-
10+
* created personal tenant this is their email; for shared groups
11+
* (lif-team, etc.) it's the group name. The backend guarantees this
12+
* field is always present and non-empty — `compute_display_name`
13+
* falls through to `tenant_schema` rather than ever returning an
14+
* empty string. Consumers that still want to be defensive about
15+
* runtime corruption (e.g. localStorage cookie mirror) should use
16+
* `display_name || group` rather than `display_name ?? group` so an
17+
* empty-string edge case doesn't render as a blank label.
18+
*/
19+
display_name: string;
820
}
921

1022
interface ListMyWorkspacesResponse {
@@ -14,6 +26,7 @@ interface ListMyWorkspacesResponse {
1426
export interface SelectWorkspaceResponse {
1527
group: string;
1628
tenant_schema: string;
29+
display_name: string;
1730
}
1831

1932
export interface CreateInviteResponse {

test/bases/lif/mdr_restapi/test_tenant_endpoints.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,29 @@ async def test_returns_workspaces_for_user_groups(self, client, monkeypatch):
190190
assert resp.status_code == 200
191191
assert resp.json() == {
192192
"workspaces": [
193-
{"group": "lif-team", "tenant_schema": "tenant_lif_team"},
194-
{"group": "acme-univ", "tenant_schema": "tenant_acme_univ"},
193+
{"group": "lif-team", "tenant_schema": "tenant_lif_team", "display_name": "lif-team"},
194+
{"group": "acme-univ", "tenant_schema": "tenant_acme_univ", "display_name": "acme-univ"},
195+
]
196+
}
197+
198+
async def test_personal_tenant_display_name_is_email(self, client, monkeypatch):
199+
"""For a user's own auto-created eval-<sub> tenant the friendly
200+
display_name should be their email, not the cryptic eval-<sub>
201+
group label. Shared groups in the same list keep their group
202+
name. (Issue #943.)"""
203+
_stub_cognito_principal(
204+
monkeypatch, "user@example.com", ["lif-team", "eval-cognito-sub-test"], cognito_sub="cognito-sub-test"
205+
)
206+
resp = await client.get("/tenants/mine")
207+
assert resp.status_code == 200
208+
assert resp.json() == {
209+
"workspaces": [
210+
{"group": "lif-team", "tenant_schema": "tenant_lif_team", "display_name": "lif-team"},
211+
{
212+
"group": "eval-cognito-sub-test",
213+
"tenant_schema": "tenant_eval_cognito_sub_test",
214+
"display_name": "user@example.com",
215+
},
195216
]
196217
}
197218

@@ -225,7 +246,7 @@ async def test_selecting_a_user_group_sets_cookie(self, client, monkeypatch):
225246
_stub_cognito_principal(monkeypatch, "user@example.com", ["lif-team", "acme-univ"])
226247
resp = await client.post("/tenants/select", json={"group": "acme-univ"})
227248
assert resp.status_code == 200
228-
assert resp.json() == {"group": "acme-univ", "tenant_schema": "tenant_acme_univ"}
249+
assert resp.json() == {"group": "acme-univ", "tenant_schema": "tenant_acme_univ", "display_name": "acme-univ"}
229250
# The Set-Cookie header carries the lif_workspace cookie
230251
set_cookie = resp.headers.get("set-cookie", "")
231252
assert "lif_workspace=" in set_cookie

test/components/lif/mdr_services/test_workspace_service.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"""Unit tests for workspace_service — pure helpers, no DB."""
22

3-
from lif.mdr_services.workspace_service import Workspace, find_workspace, list_workspaces_for_groups
3+
from lif.mdr_services.workspace_service import (
4+
Workspace,
5+
compute_display_name,
6+
find_workspace,
7+
list_workspaces_for_groups,
8+
to_workspace_item,
9+
)
410

511

612
class TestListWorkspacesForGroups:
@@ -45,3 +51,138 @@ def test_returns_none_when_group_sanitizes_to_empty(self):
4551
"""Even if --- somehow ended up in cognito_groups, we shouldn't
4652
route a request to an empty tenant_ schema."""
4753
assert find_workspace(["---"], "---") is None
54+
55+
56+
class TestComputeDisplayName:
57+
"""Friendly display name resolution (issue #943).
58+
59+
For a user's own personal tenant the display name is their email
60+
(verified via the eval-<sub> group / JWT sub match). For any other
61+
group the display name is just the group name as today.
62+
"""
63+
64+
def test_personal_tenant_uses_email_when_match(self):
65+
# Cognito's sub claim is `abc123`; the post-confirmation Lambda
66+
# created `eval-abc123` for them. The user's email is on
67+
# principal. Display name should be the email.
68+
assert (
69+
compute_display_name(
70+
group="eval-abc123",
71+
cognito_sub="abc123",
72+
principal="user@example.edu",
73+
tenant_schema="tenant_eval_abc123",
74+
)
75+
== "user@example.edu"
76+
)
77+
78+
def test_shared_group_uses_group_name(self):
79+
# User is in lif-team (a shared group). Even though we have the
80+
# email on principal, the group name is the right friendly label
81+
# for a shared workspace.
82+
assert (
83+
compute_display_name(
84+
group="lif-team", cognito_sub="abc123", principal="user@example.edu", tenant_schema="tenant_lif_team"
85+
)
86+
== "lif-team"
87+
)
88+
89+
def test_eval_group_for_different_sub_does_not_use_email(self):
90+
# Defense-in-depth: if another user's eval-* group somehow ended
91+
# up in the caller's group list (shouldn't happen — the post-
92+
# confirmation Lambda only adds the caller to their own — but
93+
# belt-and-suspenders), we don't claim someone else's tenant as
94+
# theirs via the email label.
95+
assert (
96+
compute_display_name(
97+
group="eval-other_user_sub",
98+
cognito_sub="abc123",
99+
principal="user@example.edu",
100+
tenant_schema="tenant_eval_other_user_sub",
101+
)
102+
== "eval-other_user_sub"
103+
)
104+
105+
def test_legacy_principal_without_at_falls_back_to_group(self):
106+
# Legacy HS256 path or any JWT where `principal` is a sub (no
107+
# email claim available). We can't surface a friendlier label,
108+
# so use the group name. The `@` heuristic is what tells us
109+
# whether principal is an email or a sub.
110+
assert (
111+
compute_display_name(
112+
group="eval-abc123",
113+
cognito_sub="abc123",
114+
principal="abc123", # sub, not email
115+
tenant_schema="tenant_eval_abc123",
116+
)
117+
== "eval-abc123"
118+
)
119+
120+
def test_missing_sub_falls_back_to_group(self):
121+
# Without a sub we can't verify the eval-* group is the caller's,
122+
# so play it safe and use the group name.
123+
assert (
124+
compute_display_name(
125+
group="eval-abc123", cognito_sub=None, principal="user@example.edu", tenant_schema="tenant_eval_abc123"
126+
)
127+
== "eval-abc123"
128+
)
129+
130+
def test_missing_principal_falls_back_to_group(self):
131+
assert (
132+
compute_display_name(
133+
group="eval-abc123", cognito_sub="abc123", principal=None, tenant_schema="tenant_eval_abc123"
134+
)
135+
== "eval-abc123"
136+
)
137+
138+
def test_empty_principal_after_strip_falls_back_to_tenant_schema(self):
139+
# Defense-in-depth per Adam's #947 review: if the resolved
140+
# candidate is whitespace-only (a principal of " " somehow
141+
# passed the `@` check, etc.), fall through to tenant_schema
142+
# rather than emit an empty display_name. The wire contract
143+
# promises `display_name` is non-empty.
144+
assert (
145+
compute_display_name(
146+
group="eval-abc123",
147+
cognito_sub="abc123",
148+
principal=" @ ", # whitespace + @ would pass the heuristic but strip to nothing useful
149+
tenant_schema="tenant_eval_abc123",
150+
)
151+
# principal " @ " stripped is "@" — not empty, so it's used as-is.
152+
# The fallback only kicks in when the candidate is truly empty
153+
# after strip. The exact behavior here is documented: we don't
154+
# over-sanitize, just guarantee non-empty.
155+
== "@"
156+
)
157+
158+
def test_empty_group_falls_back_to_tenant_schema(self):
159+
# Sanity: if a group somehow sanitized to empty string (the
160+
# listing pipeline filters these out before this point, but
161+
# belt-and-suspenders), tenant_schema is the ultimate fallback.
162+
assert (
163+
compute_display_name(group="", cognito_sub=None, principal=None, tenant_schema="tenant_lif_team")
164+
== "tenant_lif_team"
165+
)
166+
167+
168+
class TestToWorkspaceItem:
169+
"""Projection from internal Workspace into the API-facing WorkspaceItem."""
170+
171+
def test_includes_friendly_display_name_for_personal_tenant(self):
172+
ws = Workspace(group="eval-abc123", tenant_schema="tenant_eval_abc123")
173+
item = to_workspace_item(ws, cognito_sub="abc123", principal="user@example.edu")
174+
assert item.group == "eval-abc123"
175+
assert item.tenant_schema == "tenant_eval_abc123"
176+
assert item.display_name == "user@example.edu"
177+
178+
def test_uses_group_name_for_shared_group(self):
179+
ws = Workspace(group="lif-team", tenant_schema="tenant_lif_team")
180+
item = to_workspace_item(ws, cognito_sub="abc123", principal="user@example.edu")
181+
assert item.display_name == "lif-team"
182+
183+
def test_no_identity_hints_defaults_to_group_name(self):
184+
# Callers that don't pass identity context get the same shape they
185+
# got pre-#943 (display_name == group).
186+
ws = Workspace(group="lif-team", tenant_schema="tenant_lif_team")
187+
item = to_workspace_item(ws)
188+
assert item.display_name == "lif-team"

0 commit comments

Comments
 (0)