diff --git a/.changeset/cyan-shoes-return.md b/.changeset/cyan-shoes-return.md new file mode 100644 index 00000000000..911afd6abf5 --- /dev/null +++ b/.changeset/cyan-shoes-return.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Don't display impersonation overlay for agents diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 894e0639734..f839def8c18 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -9,6 +9,7 @@ import { import { retry } from '@clerk/shared/retry'; import type { ActClaim, + AgentActClaim, CheckAuthorization, ClientResource, EmailCodeConfig, @@ -59,6 +60,7 @@ export class Session extends BaseResource implements SessionResource { lastActiveToken!: TokenResource | null; lastActiveOrganizationId!: string | null; actor!: ActClaim | null; + agent!: AgentActClaim | null; user!: UserResource | null; publicUserData!: PublicUserData; factorVerificationAge: [number, number] | null = null; @@ -382,6 +384,7 @@ export class Session extends BaseResource implements SessionResource { this.lastActiveAt = unixEpochToDate(data.last_active_at || undefined); this.lastActiveOrganizationId = data.last_active_organization_id; this.actor = data.actor || null; + this.agent = data.actor?.type === 'agent' ? (data.actor as AgentActClaim) : null; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); this.user = new User(data.user); diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 0968ea56f8c..6926672c397 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1526,4 +1526,57 @@ describe('Session', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); }); + + describe('agent', () => { + it('sets agent to null when actor is null', () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + expect(session.actor).toBeNull(); + expect(session.agent).toBeNull(); + }); + + it('sets agent to null when actor has no type (impersonation)', () => { + const actor = { sub: 'user_2' }; + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toBeNull(); + }); + + it('sets agent to the actor when actor has type "agent"', () => { + const actor = { sub: 'user_2', type: 'agent' as const }; + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toEqual(actor); + expect(session.agent?.type).toBe('agent'); + }); + }); }); diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index d0c750a87fa..f03d6738333 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -184,6 +184,11 @@ export type VersionedJwtPayload = export type JwtPayload = JWTPayloadBase & CustomJwtSessionClaims & VersionedJwtPayload; +/** + * The type of the actor claim. + */ +export type ActClaimType = 'agent'; + /** * JWT Actor - [RFC8693](https://www.rfc-editor.org/rfc/rfc8693.html#name-act-actor-claim). * @@ -191,9 +196,17 @@ export type JwtPayload = JWTPayloadBase & CustomJwtSessionClaims & VersionedJwtP */ export interface ActClaim { sub: string; + type?: ActClaimType; [x: string]: unknown; } +/** + * ActClaim narrowed to actor type `'agent'`. Use for session.agent. + * + * @inline + */ +export type AgentActClaim = ActClaim & { type: 'agent' }; + /** * The current state of the session which can only be `active` or `pending`. */ diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index ce1df40f777..0fe363ec745 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -12,7 +12,7 @@ import type { PhoneCodeSecondFactorConfig, TOTPAttempt, } from './factors'; -import type { ActClaim } from './jwtv2'; +import type { ActClaim, AgentActClaim } from './jwtv2'; import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey, @@ -227,6 +227,7 @@ export interface SessionResource extends ClerkResource { lastActiveOrganizationId: string | null; lastActiveAt: Date; actor: ActClaim | null; + agent: AgentActClaim | null; tasks: Array | null; currentTask?: SessionTask; /** diff --git a/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx new file mode 100644 index 00000000000..ee0f058dc8f --- /dev/null +++ b/packages/ui/src/components/ImpersonationFab/__tests__/ImpersonationFab.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +import { ImpersonationFab } from '../'; + +const { createFixtures } = bindCreateFixtures('UserButton'); + +describe('ImpersonationFab', () => { + it('does not render when user has no actor', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + render(, { wrapper }); + expect(document.getElementById('cl-impersonationEye')).toBeNull(); + }); + + it('renders when user has actor without type (impersonation)', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ + email_addresses: ['test@clerk.com'], + actor: { sub: 'user_impersonated' }, + }); + }); + render(, { wrapper }); + expect(document.getElementById('cl-impersonationEye')).toBeInTheDocument(); + }); + + it('does not render when user has actor with type "agent"', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ + email_addresses: ['test@clerk.com'], + actor: { sub: 'user_agent', type: 'agent' }, + }); + }); + render(, { wrapper }); + expect(document.getElementById('cl-impersonationEye')).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/ImpersonationFab/index.tsx b/packages/ui/src/components/ImpersonationFab/index.tsx index 787795d0567..b103e04826a 100644 --- a/packages/ui/src/components/ImpersonationFab/index.tsx +++ b/packages/ui/src/components/ImpersonationFab/index.tsx @@ -116,7 +116,8 @@ const ImpersonationFabInternal = () => { const { parsedInternalTheme } = useAppearance(); const containerRef = useRef(null); const actor = session?.actor; - const isImpersonating = !!actor; + const agent = session?.agent; + const isImpersonating = !!actor && !agent; //essentials for calcs const eyeWidth = parsedInternalTheme.sizes.$16; diff --git a/packages/ui/src/test/fixture-helpers.ts b/packages/ui/src/test/fixture-helpers.ts index 45525aef82d..460117de14a 100644 --- a/packages/ui/src/test/fixture-helpers.ts +++ b/packages/ui/src/test/fixture-helpers.ts @@ -51,6 +51,7 @@ const createUserFixtureHelpers = (baseClient: ClientJSON) => { external_accounts?: Array>; organization_memberships?: Array; tasks?: SessionJSON['tasks']; + actor?: SessionJSON['actor']; }; const createPublicUserData = (params: WithUserParams) => { @@ -81,7 +82,7 @@ const createUserFixtureHelpers = (baseClient: ClientJSON) => { id: baseClient.sessions.length.toString(), object: 'session', last_active_organization_id: activeOrganization, - actor: null, + actor: params.actor ?? null, user: createUser(params), public_user_data: createPublicUserData(params), created_at: new Date().getTime(),