Skip to content

feat(auth): surface SSO membership and gate team management#471

Open
ben-fornefeld wants to merge 6 commits into
mainfrom
auth/sso-initial
Open

feat(auth): surface SSO membership and gate team management#471
ben-fornefeld wants to merge 6 commits into
mainfrom
auth/sso-initial

Conversation

@ben-fornefeld

Copy link
Copy Markdown
Member

What

Surfaces SSO membership on AuthUser and gates team-management UI for SSO-managed users. Pairs with the infra/dashboard-api PR (server-side enforcement + org→team mapping).

Changes

  • AuthUser.isSso + organizationId derived from the Kratos identity's organization_id (a first-class Ory identity field, not a trait or JWT claim), populated in both fromKratosSessionIdentity and fromOryIdentity.
  • Create team: the "Create new team" item is hidden in the team switcher for SSO users.
  • Add member: the "Add new member" button is disabled with a tooltip"Members are managed by your SSO provider. Ask teammates to sign in through SSO…" — instead of opening the invite dialog.

These are UX guards; the authoritative enforcement is server-side in dashboard-api.

Tests / checks

tsc --noEmit clean for the changed files (pre-existing unrelated errors only); biome lint clean on changed files.

🤖 Generated with Claude Code

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>
@cla-bot cla-bot Bot added the cla-signed label Jun 26, 2026
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
web Ready Ready Preview, Comment Jun 29, 2026 9:40pm

Request Review

@cursor

cursor Bot commented Jun 26, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Mostly auth model and UI gating; the .gitignore change could accidentally ignore .env.example if negation no longer wins.

Overview
AuthUser now includes organizationId and isSso, set in fromKratosSessionIdentity and fromOryIdentity from the identity’s organization_id (isSso when that id is present).

The sidebar team switcher only shows Create new team when !user.isSso.

.gitignore adds a .env* entry at the bottom; it may override the existing !.env.example exception depending on ignore order.

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>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

Comment on lines +25 to +45
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>
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 when user.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 !== currentUserId

No 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

  1. An SSO user signs in. fromKratosSessionIdentity / fromOryIdentity set organizationId from identity.organization_id, and isSso = organizationId !== null.
  2. They open the team page. The header shows the disabled "Add new member" button with the SSO tooltip — UX promise established.
  3. Below, the member table renders every other teammate (member.info.id !== currentUserId, !is_default) with a clickable trash icon.
  4. The user clicks the trash icon → confirmation dialog appears → confirm → trpc.teams.removeMember fires.
  5. The paired dashboard-api PR 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.

Comment on lines +25 to +45
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>
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 disabled attribute on <Button> removes the button from the focus order entirely (per the HTML spec — disabled form controls are not focusable).
  • The wrapping <span> has no tabIndex and no role, 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.

  1. SSO user (user.isSso === true) lands on the Members page.
  2. AddMemberDialog renders the early-return branch with <TooltipTrigger asChild><span><Button disabled>…</Button></span></TooltipTrigger>.
  3. User presses Tab to 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 (no tabIndex, no role).
  4. Focus lands on whatever comes after the disabled button. No focus event ever fires on the trigger.
  5. Radix Tooltip's onFocus/onBlur handlers — forwarded via asChild — never get called, so the TooltipContent never mounts/opens.
  6. 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> to aria-disabled="true" (with onClick no-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", and aria-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.

ben-fornefeld and others added 2 commits June 29, 2026 14:39
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant