From cb1f272b606928fd2152069cf0b76790fbc3ce77 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 25 Jun 2026 20:24:45 -0700 Subject: [PATCH 1/6] feat(auth): surface SSO membership and gate team management 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 --- src/core/modules/auth/models.ts | 6 ++++ src/core/server/auth/ory/identity.ts | 15 ++++++++++ .../dashboard/members/add-member-dialog.tsx | 30 +++++++++++++++++++ src/features/dashboard/sidebar/menu.tsx | 18 ++++++----- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/core/modules/auth/models.ts b/src/core/modules/auth/models.ts index 70d5ca88f..d06a05a70 100644 --- a/src/core/modules/auth/models.ts +++ b/src/core/modules/auth/models.ts @@ -9,4 +9,10 @@ export type AuthUser = { providers: string[] canChangeEmail: boolean canChangePassword: boolean + // Ory organization id when the identity belongs to an SSO organization, + // otherwise null. `isSso` is a convenience flag derived from it. SSO members + // are managed by their identity provider: they can't create teams or add + // members. + organizationId: string | null + isSso: boolean } diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index a724db90e..1c3471488 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -62,6 +62,14 @@ function readPublicPicture(metadataPublic: unknown): string | null { return readString(meta, 'picture') } +// organization_id is a first-class Ory identity field, set when the identity +// authenticated through an organization's SSO connection. Absent/empty means the +// identity is not part of an SSO organization. +function readOrganizationId(organizationId?: string | null): string | null { + const trimmed = typeof organizationId === 'string' ? organizationId.trim() : '' + return trimmed === '' ? null : trimmed +} + // Build the user from a live Kratos session identity (whoami) — the source of // truth for getAuthContext. The session identity carries traits but not // credentials, so provider/credential flags stay false — use fromOryIdentity @@ -71,11 +79,13 @@ export function fromKratosSessionIdentity(identity: { external_id?: string | null traits?: unknown metadata_public?: unknown + organization_id?: string | null }): AuthUser { const traits = parseOryTraits(identity.traits, { identityId: identity.id, source: 'kratos_session', }) + const organizationId = readOrganizationId(identity.organization_id) return { id: requireExternalId(identity), identityId: identity.id, @@ -85,6 +95,8 @@ export function fromKratosSessionIdentity(identity: { providers: [], canChangeEmail: false, canChangePassword: false, + organizationId, + isSso: organizationId !== null, } } @@ -105,6 +117,7 @@ export function fromOryIdentity(identity: Identity): AuthUser { ) const hasOidcCredential = hasLinkedOidcCredential(identity.credentials?.oidc) const canChangePassword = hasPasswordCredential && !hasOidcCredential + const organizationId = readOrganizationId(identity.organization_id) return { id: requireExternalId(identity), @@ -117,6 +130,8 @@ export function fromOryIdentity(identity: Identity): AuthUser { // settings/verification flows instead of patching traits directly. canChangeEmail: false, canChangePassword, + organizationId, + isSso: organizationId !== null, } } diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx index cfb47eee1..0a22e74f8 100644 --- a/src/features/dashboard/members/add-member-dialog.tsx +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -1,6 +1,7 @@ 'use client' import { useState } from 'react' +import { useDashboard } from '@/features/dashboard/context' import { AddMemberForm } from '@/features/dashboard/members/add-member-form' import { Button } from '@/ui/primitives/button' import { @@ -11,10 +12,39 @@ import { DialogTrigger, } from '@/ui/primitives/dialog' import { AddIcon } from '@/ui/primitives/icons' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/ui/primitives/tooltip' export const AddMemberDialog = () => { + const { user } = useDashboard() const [open, setOpen] = useState(false) + // SSO-managed teams have their membership driven by the identity provider: + // teammates join by signing in through SSO, not by manual invitation. + if (user.isSso) { + return ( + + + {/* A disabled button swallows the pointer events the tooltip trigger + needs, so the wrapping span carries them instead. */} + + + + + + Members are managed by your SSO provider. Ask teammates to sign in + through SSO to join this team automatically. + + + ) + } + return ( diff --git a/src/features/dashboard/sidebar/menu.tsx b/src/features/dashboard/sidebar/menu.tsx index 5e97d2e44..87631f546 100644 --- a/src/features/dashboard/sidebar/menu.tsx +++ b/src/features/dashboard/sidebar/menu.tsx @@ -31,7 +31,7 @@ import DashboardSidebarMenuTeams from './menu-teams' import { TeamAvatar } from './team-avatar' export default function DashboardSidebarMenu() { - const { team } = useDashboard() + const { team, user } = useDashboard() const { enabled: postHogEnabled } = useAppPostHogProvider() const posthog = usePostHog() const [createTeamOpen, setCreateTeamOpen] = useState(false) @@ -83,12 +83,16 @@ export default function DashboardSidebarMenu() { > - setCreateTeamOpen(true)} - > - Create new team - + {/* SSO-managed members can't create teams; their team membership is + driven entirely by their identity provider. */} + {!user.isSso && ( + setCreateTeamOpen(true)} + > + Create new team + + )} From 068e12878545455a0ed5e031e70e6047c3b1fea0 Mon Sep 17 00:00:00 2001 From: ben-fornefeld <50748440+ben-fornefeld@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:25:55 +0000 Subject: [PATCH 2/6] style: apply biome formatting --- src/core/server/auth/ory/identity.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index 1c3471488..eca177f7d 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -66,7 +66,8 @@ function readPublicPicture(metadataPublic: unknown): string | null { // authenticated through an organization's SSO connection. Absent/empty means the // identity is not part of an SSO organization. function readOrganizationId(organizationId?: string | null): string | null { - const trimmed = typeof organizationId === 'string' ? organizationId.trim() : '' + const trimmed = + typeof organizationId === 'string' ? organizationId.trim() : '' return trimmed === '' ? null : trimmed } From a4989f133b13214794aa21993626073212d4239e Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 26 Jun 2026 11:33:13 -0700 Subject: [PATCH 3/6] refactor(auth): simplify organization_id read, trim comments 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 --- src/core/modules/auth/models.ts | 5 +---- src/core/server/auth/ory/identity.ts | 13 ++----------- .../dashboard/members/add-member-dialog.tsx | 2 -- src/features/dashboard/sidebar/menu.tsx | 2 -- 4 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/core/modules/auth/models.ts b/src/core/modules/auth/models.ts index d06a05a70..de46a462b 100644 --- a/src/core/modules/auth/models.ts +++ b/src/core/modules/auth/models.ts @@ -9,10 +9,7 @@ export type AuthUser = { providers: string[] canChangeEmail: boolean canChangePassword: boolean - // Ory organization id when the identity belongs to an SSO organization, - // otherwise null. `isSso` is a convenience flag derived from it. SSO members - // are managed by their identity provider: they can't create teams or add - // members. + // Ory organization id when the identity signed in via org SSO, else null. organizationId: string | null isSso: boolean } diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index eca177f7d..02b9671c4 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -62,15 +62,6 @@ function readPublicPicture(metadataPublic: unknown): string | null { return readString(meta, 'picture') } -// organization_id is a first-class Ory identity field, set when the identity -// authenticated through an organization's SSO connection. Absent/empty means the -// identity is not part of an SSO organization. -function readOrganizationId(organizationId?: string | null): string | null { - const trimmed = - typeof organizationId === 'string' ? organizationId.trim() : '' - return trimmed === '' ? null : trimmed -} - // Build the user from a live Kratos session identity (whoami) — the source of // truth for getAuthContext. The session identity carries traits but not // credentials, so provider/credential flags stay false — use fromOryIdentity @@ -86,7 +77,7 @@ export function fromKratosSessionIdentity(identity: { identityId: identity.id, source: 'kratos_session', }) - const organizationId = readOrganizationId(identity.organization_id) + const organizationId = identity.organization_id || null return { id: requireExternalId(identity), identityId: identity.id, @@ -118,7 +109,7 @@ export function fromOryIdentity(identity: Identity): AuthUser { ) const hasOidcCredential = hasLinkedOidcCredential(identity.credentials?.oidc) const canChangePassword = hasPasswordCredential && !hasOidcCredential - const organizationId = readOrganizationId(identity.organization_id) + const organizationId = identity.organization_id || null return { id: requireExternalId(identity), diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx index 0a22e74f8..4aba8a0b3 100644 --- a/src/features/dashboard/members/add-member-dialog.tsx +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -22,8 +22,6 @@ export const AddMemberDialog = () => { const { user } = useDashboard() const [open, setOpen] = useState(false) - // SSO-managed teams have their membership driven by the identity provider: - // teammates join by signing in through SSO, not by manual invitation. if (user.isSso) { return ( diff --git a/src/features/dashboard/sidebar/menu.tsx b/src/features/dashboard/sidebar/menu.tsx index 87631f546..22f0941f4 100644 --- a/src/features/dashboard/sidebar/menu.tsx +++ b/src/features/dashboard/sidebar/menu.tsx @@ -83,8 +83,6 @@ export default function DashboardSidebarMenu() { > - {/* SSO-managed members can't create teams; their team membership is - driven entirely by their identity provider. */} {!user.isSso && ( Date: Sun, 28 Jun 2026 21:55:43 -0700 Subject: [PATCH 4/6] debug logs --- .gitignore | 1 + src/core/server/auth/ory/identity.ts | 32 ++++++++++++- .../server/auth/ory/kratos-session-edge.ts | 39 ++++++++++++++-- src/core/server/auth/ory/session.ts | 45 ++++++++++++++++++- 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index abe504f86..6d6df7e37 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ next-env.d.ts # tooling /template +.env* diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index 02b9671c4..99441b7bc 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -78,6 +78,22 @@ export function fromKratosSessionIdentity(identity: { source: 'kratos_session', }) const organizationId = identity.organization_id || null + const isSso = organizationId !== null + + l.debug( + { + key: 'sso_debug:identity:from_kratos_session', + context: { + identity_id: identity.id, + organization_id_raw: identity.organization_id, + organization_id_resolved: organizationId, + isSso, + source: 'kratos_session', + }, + }, + 'SSO debug: fromKratosSessionIdentity organization_id resolution' + ) + return { id: requireExternalId(identity), identityId: identity.id, @@ -88,7 +104,7 @@ export function fromKratosSessionIdentity(identity: { canChangeEmail: false, canChangePassword: false, organizationId, - isSso: organizationId !== null, + isSso, } } @@ -111,6 +127,20 @@ export function fromOryIdentity(identity: Identity): AuthUser { const canChangePassword = hasPasswordCredential && !hasOidcCredential const organizationId = identity.organization_id || null + l.debug( + { + key: 'sso_debug:identity:from_ory_identity', + context: { + identity_id: identity.id, + organization_id_raw: identity.organization_id, + organization_id_resolved: organizationId, + isSso: organizationId !== null, + source: 'admin_identity', + }, + }, + 'SSO debug: fromOryIdentity organization_id resolution' + ) + return { id: requireExternalId(identity), identityId: identity.id, diff --git a/src/core/server/auth/ory/kratos-session-edge.ts b/src/core/server/auth/ory/kratos-session-edge.ts index 29671a409..311f9762d 100644 --- a/src/core/server/auth/ory/kratos-session-edge.ts +++ b/src/core/server/auth/ory/kratos-session-edge.ts @@ -1,4 +1,5 @@ import type { NextRequest } from 'next/server' +import { l } from '@/core/shared/clients/logger/logger' import { APP_OWNED_COOKIES } from './session-cookie' // Edge-safe Kratos session check for the middleware gate. getServerSession() @@ -25,13 +26,45 @@ export async function isKratosSessionActive( `${sdkUrl.replace(/\/$/, '')}/sessions/whoami`, { headers: { cookie, accept: 'application/json' } } ) - if (!response.ok) return false + if (!response.ok) { + l.debug( + { + key: 'sso_debug:edge:whoami_failed', + context: { status: response.status, sdk_url: sdkUrl }, + }, + 'SSO debug: edge whoami returned non-OK status' + ) + return false + } const session = (await response.json()) as { active?: boolean - identity?: { external_id?: string | null } + identity?: { + id?: string + external_id?: string | null + organization_id?: string | null + } } + l.debug( + { + key: 'sso_debug:edge:whoami_response', + context: { + active: session.active, + identity_id: session.identity?.id, + organization_id: session.identity?.organization_id ?? null, + has_external_id: !!session.identity?.external_id, + }, + }, + 'SSO debug: edge whoami response' + ) return session.active === true && !!session.identity?.external_id - } catch { + } catch (error) { + l.debug( + { + key: 'sso_debug:edge:whoami_error', + error: error instanceof Error ? error.message : String(error), + }, + 'SSO debug: edge whoami threw' + ) return false } } diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index 6888f13bf..48ae71059 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -45,6 +45,23 @@ const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?re export async function getAuthContext(): Promise { const kratos = await readKratosSession() + + l.debug( + { + key: 'sso_debug:auth_context:kratos_session', + context: { + active: kratos?.active, + identity_id: kratos?.identity?.id, + organization_id: kratos?.identity?.organization_id ?? null, + identity_keys: kratos?.identity + ? Object.keys(kratos.identity) + : null, + has_external_id: !!kratos?.identity?.external_id, + }, + }, + 'SSO debug: raw Kratos session from getServerSession()' + ) + if (!kratos?.active || !kratos.identity) return null // public.users.id lives only on the Kratos identity's external_id. Without it @@ -66,8 +83,22 @@ export async function getAuthContext(): Promise { const tokens = await readSessionTokens() if (!tokens?.accessToken) return null + const user = fromKratosSessionIdentity(kratos.identity) + + l.debug( + { + key: 'sso_debug:auth_context:resolved', + context: { + user_id: user.id, + isSso: user.isSso, + organizationId: user.organizationId, + }, + }, + 'SSO debug: getAuthContext final resolved user' + ) + return { - user: fromKratosSessionIdentity(kratos.identity), + user, accessToken: tokens.accessToken, } } @@ -91,6 +122,18 @@ export async function getUserProfile(): Promise { includeCredential: ACCOUNT_IDENTITY_CREDENTIALS, }) + l.debug( + { + key: 'sso_debug:user_profile:admin_identity', + context: { + identity_id: identity?.id, + organization_id: identity?.organization_id ?? null, + found: !!identity, + }, + }, + 'SSO debug: getUserProfile admin API identity lookup' + ) + return identity ? fromOryIdentity(identity) : null } From a1d17463334d5a5c109e5a29bf55d1fba6fe0276 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 29 Jun 2026 14:39:21 -0700 Subject: [PATCH 5/6] revert(members): drop SSO gating from the Add Member dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../dashboard/members/add-member-dialog.tsx | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx index 4aba8a0b3..cfb47eee1 100644 --- a/src/features/dashboard/members/add-member-dialog.tsx +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -1,7 +1,6 @@ 'use client' import { useState } from 'react' -import { useDashboard } from '@/features/dashboard/context' import { AddMemberForm } from '@/features/dashboard/members/add-member-form' import { Button } from '@/ui/primitives/button' import { @@ -12,37 +11,10 @@ import { DialogTrigger, } from '@/ui/primitives/dialog' import { AddIcon } from '@/ui/primitives/icons' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/ui/primitives/tooltip' export const AddMemberDialog = () => { - const { user } = useDashboard() const [open, setOpen] = useState(false) - if (user.isSso) { - return ( - - - {/* A disabled button swallows the pointer events the tooltip trigger - needs, so the wrapping span carries them instead. */} - - - - - - Members are managed by your SSO provider. Ask teammates to sign in - through SSO to join this team automatically. - - - ) - } - return ( From db52ffc55c89a2524944e1e67968024b4c518c81 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 29 Jun 2026 14:39:21 -0700 Subject: [PATCH 6/6] chore(auth): remove SSO debug logging 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 --- src/core/server/auth/ory/identity.ts | 31 +------------ .../server/auth/ory/kratos-session-edge.ts | 39 ++-------------- src/core/server/auth/ory/session.ts | 45 +------------------ 3 files changed, 5 insertions(+), 110 deletions(-) diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index 99441b7bc..9248a4d07 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -78,21 +78,6 @@ export function fromKratosSessionIdentity(identity: { source: 'kratos_session', }) const organizationId = identity.organization_id || null - const isSso = organizationId !== null - - l.debug( - { - key: 'sso_debug:identity:from_kratos_session', - context: { - identity_id: identity.id, - organization_id_raw: identity.organization_id, - organization_id_resolved: organizationId, - isSso, - source: 'kratos_session', - }, - }, - 'SSO debug: fromKratosSessionIdentity organization_id resolution' - ) return { id: requireExternalId(identity), @@ -104,7 +89,7 @@ export function fromKratosSessionIdentity(identity: { canChangeEmail: false, canChangePassword: false, organizationId, - isSso, + isSso: organizationId !== null, } } @@ -127,20 +112,6 @@ export function fromOryIdentity(identity: Identity): AuthUser { const canChangePassword = hasPasswordCredential && !hasOidcCredential const organizationId = identity.organization_id || null - l.debug( - { - key: 'sso_debug:identity:from_ory_identity', - context: { - identity_id: identity.id, - organization_id_raw: identity.organization_id, - organization_id_resolved: organizationId, - isSso: organizationId !== null, - source: 'admin_identity', - }, - }, - 'SSO debug: fromOryIdentity organization_id resolution' - ) - return { id: requireExternalId(identity), identityId: identity.id, diff --git a/src/core/server/auth/ory/kratos-session-edge.ts b/src/core/server/auth/ory/kratos-session-edge.ts index 311f9762d..29671a409 100644 --- a/src/core/server/auth/ory/kratos-session-edge.ts +++ b/src/core/server/auth/ory/kratos-session-edge.ts @@ -1,5 +1,4 @@ import type { NextRequest } from 'next/server' -import { l } from '@/core/shared/clients/logger/logger' import { APP_OWNED_COOKIES } from './session-cookie' // Edge-safe Kratos session check for the middleware gate. getServerSession() @@ -26,45 +25,13 @@ export async function isKratosSessionActive( `${sdkUrl.replace(/\/$/, '')}/sessions/whoami`, { headers: { cookie, accept: 'application/json' } } ) - if (!response.ok) { - l.debug( - { - key: 'sso_debug:edge:whoami_failed', - context: { status: response.status, sdk_url: sdkUrl }, - }, - 'SSO debug: edge whoami returned non-OK status' - ) - return false - } + if (!response.ok) return false const session = (await response.json()) as { active?: boolean - identity?: { - id?: string - external_id?: string | null - organization_id?: string | null - } + identity?: { external_id?: string | null } } - l.debug( - { - key: 'sso_debug:edge:whoami_response', - context: { - active: session.active, - identity_id: session.identity?.id, - organization_id: session.identity?.organization_id ?? null, - has_external_id: !!session.identity?.external_id, - }, - }, - 'SSO debug: edge whoami response' - ) return session.active === true && !!session.identity?.external_id - } catch (error) { - l.debug( - { - key: 'sso_debug:edge:whoami_error', - error: error instanceof Error ? error.message : String(error), - }, - 'SSO debug: edge whoami threw' - ) + } catch { return false } } diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index 48ae71059..6888f13bf 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -45,23 +45,6 @@ const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?re export async function getAuthContext(): Promise { const kratos = await readKratosSession() - - l.debug( - { - key: 'sso_debug:auth_context:kratos_session', - context: { - active: kratos?.active, - identity_id: kratos?.identity?.id, - organization_id: kratos?.identity?.organization_id ?? null, - identity_keys: kratos?.identity - ? Object.keys(kratos.identity) - : null, - has_external_id: !!kratos?.identity?.external_id, - }, - }, - 'SSO debug: raw Kratos session from getServerSession()' - ) - if (!kratos?.active || !kratos.identity) return null // public.users.id lives only on the Kratos identity's external_id. Without it @@ -83,22 +66,8 @@ export async function getAuthContext(): Promise { const tokens = await readSessionTokens() if (!tokens?.accessToken) return null - const user = fromKratosSessionIdentity(kratos.identity) - - l.debug( - { - key: 'sso_debug:auth_context:resolved', - context: { - user_id: user.id, - isSso: user.isSso, - organizationId: user.organizationId, - }, - }, - 'SSO debug: getAuthContext final resolved user' - ) - return { - user, + user: fromKratosSessionIdentity(kratos.identity), accessToken: tokens.accessToken, } } @@ -122,18 +91,6 @@ export async function getUserProfile(): Promise { includeCredential: ACCOUNT_IDENTITY_CREDENTIALS, }) - l.debug( - { - key: 'sso_debug:user_profile:admin_identity', - context: { - identity_id: identity?.id, - organization_id: identity?.organization_id ?? null, - found: !!identity, - }, - }, - 'SSO debug: getUserProfile admin API identity lookup' - ) - return identity ? fromOryIdentity(identity) : null }