feat(auth): surface SSO membership and gate team management#471
feat(auth): surface SSO membership and gate team management#471ben-fornefeld wants to merge 6 commits into
Conversation
Derive isSso/organizationId on AuthUser from the Kratos identity's organization_id (first-class field, not a trait). SSO-managed users have their membership driven by their identity provider, so: - hide "Create new team" in the team switcher - disable "Add new member" with a tooltip pointing teammates to SSO sign-in Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryLow Risk Overview The sidebar team switcher only shows Create new team when .gitignore adds a Reviewed by Cursor Bugbot for commit db52ffc. Bugbot is set up for automated code reviews on this repo. Configure here. |
Address PR review: - read organization_id directly (`identity.organization_id || null`) instead of trimming/typeof-guarding an Ory-assigned UUID that never carries whitespace; drop the readOrganizationId helper - trim comments to non-obvious context only Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
| if (user.isSso) { | ||
| return ( | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| {/* A disabled button swallows the pointer events the tooltip trigger | ||
| needs, so the wrapping span carries them instead. */} | ||
| <span> | ||
| <Button type="button" disabled> | ||
| <AddIcon /> | ||
| Add new member | ||
| </Button> | ||
| </span> | ||
| </TooltipTrigger> | ||
| <TooltipContent side="bottom" className="max-w-[260px]"> | ||
| Members are managed by your SSO provider. Ask teammates to sign in | ||
| through SSO to join this team automatically. | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| ) | ||
| } | ||
|
|
There was a problem hiding this comment.
🟡 The PR gates the add-member and create-team actions for SSO users but leaves the symmetrical remove-member action fully exposed. shouldShowRemoveMemberAction in src/features/dashboard/members/member-table.utils.ts:16-19 only checks !is_default and the current-user guard, so SSO users still see a trash icon next to every teammate and can fire trpc.teams.removeMember — only discovering the constraint via an error toast. Consider hiding/disabling the remove trigger when user.isSso for symmetry with the add-member gate added here (nit — server enforcement is authoritative).
Extended reasoning...
The asymmetry
This PR introduces an explicit UX guard for SSO-managed users on two team-management actions:
- Add member (
add-member-dialog.tsx:25-45) — renders a disabled button with the tooltip "Members are managed by your SSO provider. Ask teammates to sign in through SSO to join this team automatically." - Create team (
sidebar/menu.tsx:86-93) — the menu item is hidden entirely whenuser.isSso.
But the third symmetrical action — removing a member — is left untouched. shouldShowRemoveMemberAction in src/features/dashboard/members/member-table.utils.ts:16-19 reads:
const shouldShowRemoveMemberAction = (
member: TeamMember,
currentUserId?: string
): boolean => !member.relation.is_default && member.info.id !== currentUserIdNo isSso check. In member-table-row.tsx:110 the row reads showRemove from that helper, and AddedCell (lines 263-278) unconditionally renders RemoveMemberDialog with a trash IconButton trigger that calls removeMemberMutation.mutate({ teamSlug, userId }). Confirmed via grep: isSso is referenced only in add-member-dialog.tsx and sidebar/menu.tsx — the remove flow is entirely unaware of SSO status.
Step-by-step proof
- An SSO user signs in.
fromKratosSessionIdentity/fromOryIdentitysetorganizationIdfromidentity.organization_id, andisSso = organizationId !== null. - They open the team page. The header shows the disabled "Add new member" button with the SSO tooltip — UX promise established.
- Below, the member table renders every other teammate (
member.info.id !== currentUserId,!is_default) with a clickable trash icon. - The user clicks the trash icon → confirmation dialog appears → confirm →
trpc.teams.removeMemberfires. - The paired
dashboard-apiPR rejects this server-side; the user sees a generic error toast instead of the polished SSO tooltip the add path uses. The promise "members are managed by your SSO provider" is silently violated for the symmetrical action.
Impact and severity
Server-side enforcement is authoritative (per the PR description), so this is not a security or correctness defect — it's a UX consistency gap. The user can't actually mutate state. But the inconsistency is real: the PR title is "gate team management" (general), and the tooltip text establishes a broader contract than just "adds." The fix is small: have shouldShowRemoveMemberAction (or the call site) consult user.isSso, mirroring the pattern from menu.tsx.
Addressing the refutation
A refuter argued this is feature scope, not a bug: the PR description scopes itself to Create-team and Add-member, the file isn't touched, and the tooltip is contextual to the add action only. Those points are fair, and they're why this is filed as a nit, not a normal-severity defect:
- This isn't a regression — the remove flow behaved the same before this PR.
- Server-side is the source of truth, so no security/correctness implication.
- The tooltip is literally about adding, not a global SSO claim.
That said, it's still worth flagging on the PR: the author may want to either (a) gate remove for symmetry, or (b) explicitly note that SSO admins can still remove members as a drift-correction escape hatch. Either decision is fine; silently shipping an inconsistent UX where the trash icon works but the add button doesn't is the worst of the three options.
| if (user.isSso) { | ||
| return ( | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| {/* A disabled button swallows the pointer events the tooltip trigger | ||
| needs, so the wrapping span carries them instead. */} | ||
| <span> | ||
| <Button type="button" disabled> | ||
| <AddIcon /> | ||
| Add new member | ||
| </Button> | ||
| </span> | ||
| </TooltipTrigger> | ||
| <TooltipContent side="bottom" className="max-w-[260px]"> | ||
| Members are managed by your SSO provider. Ask teammates to sign in | ||
| through SSO to join this team automatically. | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| ) | ||
| } | ||
|
|
There was a problem hiding this comment.
🟡 The disabled Add new member button is wrapped in a bare <span> for tooltip pointer events, but neither element is focusable — the native disabled attribute removes the button from tab order, and the <span> has no tabIndex/role. Radix Tooltip opens on hover OR focus, so keyboard and AT users never see the "Members are managed by your SSO provider…" explanation, defeating the purpose of disable-with-tooltip. Fix by switching to aria-disabled on the Button (so it stays focusable) or by adding tabIndex={0} (and appropriate role/aria-disabled) to the wrapping <span>.
Extended reasoning...
What the bug is. In src/features/dashboard/members/add-member-dialog.tsx (lines 25–45), the SSO-blocked branch renders a TooltipTrigger asChild around a <span> that wraps a natively disabled <Button>. The intent — per the inline comment — is to use the <span> to receive pointer events that a disabled button would swallow. That works for mouse hover, but does nothing for focus.
Why the tooltip is invisible to keyboard/AT users. Radix Tooltip (@radix-ui/react-tooltip, used via src/ui/primitives/tooltip.tsx) opens on hover or focus of the trigger element. For the focus path, the trigger must be in the tab order:
- The HTML
disabledattribute on<Button>removes the button from the focus order entirely (per the HTML spec — disabled form controls are not focusable). - The wrapping
<span>has notabIndexand norole, so it is not focusable by default either.
Result: neither element can receive focus, so a keyboard-only or screen-reader user navigating with Tab simply skips past this control. The explanatory copy — Members are managed by your SSO provider. Ask teammates to sign in through SSO to join this team automatically. — never reaches them.
Why this matters. The whole point of disable-with-tooltip (versus simply hiding the button) is to teach the user why the action is unavailable. SSO users on keyboard or assistive tech see a greyed-out, unreachable button with no explanation, which is a regression in discoverability versus the non-SSO path. Radix's own documentation explicitly calls out this pitfall: "If you want to use a Tooltip with a disabled button, you should set the trigger to aria-disabled instead of disabled."
Step-by-step proof.
- SSO user (
user.isSso === true) lands on the Members page. AddMemberDialogrenders the early-return branch with<TooltipTrigger asChild><span><Button disabled>…</Button></span></TooltipTrigger>.- User presses
Tabto traverse the page. The browser walks focusable elements;<button disabled>is skipped per HTML spec, and the bare<span>is not in the tab order (notabIndex, norole). - Focus lands on whatever comes after the disabled button. No focus event ever fires on the trigger.
- Radix Tooltip's
onFocus/onBlurhandlers — forwarded viaasChild— never get called, so theTooltipContentnever mounts/opens. - The user has no way to learn why the action is disabled.
Fix. Two equivalent options, both standard Radix-recommended patterns:
- (Preferred) Switch the
<Button>toaria-disabled="true"(withonClickno-oping or omitted) so the button stays focusable and Radix's focus trigger works. Drop the wrapping<span>entirely. - Or add
tabIndex={0},role="button", andaria-disabled="true"to the wrapping<span>so it can receive focus while the inner button stays visually disabled.
Severity rationale. Filing as a nit: the functional gate is still honored (the button is non-actionable, and the server-side enforcement in dashboard-api is the real gate). The visual disabled state still signals "cannot do this" to keyboard users — only the explanatory copy is hidden from them. Worth fixing here since the pattern was introduced by this PR, but not a merge blocker.
Invites to SSO teams are now allowed and validated server-side (the invitee must be an org account), so the dialog needs no team-SSO knowledge — back to the plain button. The create-team gate (user.isSso) stays. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the temporary sso_debug:* logs added while debugging organization_id resolution (identity.ts, session.ts, kratos-session-edge.ts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b4b8ec0 to
db52ffc
Compare
What
Surfaces SSO membership on
AuthUserand gates team-management UI for SSO-managed users. Pairs with the infra/dashboard-apiPR (server-side enforcement + org→team mapping).Changes
AuthUser.isSso+organizationIdderived from the Kratos identity'sorganization_id(a first-class Ory identity field, not a trait or JWT claim), populated in bothfromKratosSessionIdentityandfromOryIdentity.These are UX guards; the authoritative enforcement is server-side in
dashboard-api.Tests / checks
tsc --noEmitclean for the changed files (pre-existing unrelated errors only);biome lintclean on changed files.🤖 Generated with Claude Code