Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/shared/src/types/jwtv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ type JWTPayloadBase = {
*/
sts?: SessionStatusClaim;

/**
* Reserved session claim. The claim shape is opaque and may change
* without notice. Do not depend on its presence or absence for
* authorization checks.
*/
ams?: AmsClaim;

/**
* Any other JWT Claim Set member.
*/
Expand Down Expand Up @@ -213,3 +220,11 @@ export type AgentActClaim = ActClaim & { type: 'agent' };
* The current state of the session which can only be `active` or `pending`.
*/
export type SessionStatusClaim = Extract<SessionStatus, 'active' | 'pending'>;

/**
* Opaque optional `ams` session claim. Consumers must not inspect its value
* or depend on its presence or absence for authorization checks.
*
* @inline
*/
export type AmsClaim = unknown;
28 changes: 16 additions & 12 deletions packages/ui/src/components/UserButton/SessionActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { USER_BUTTON_ITEM_ID } from '../../constants';
import { useUserButtonContext } from '../../contexts';
import type { LocalizationKey } from '../../customizables';
import { descriptors, Flex, localizationKeys } from '../../customizables';
import { useAms } from '../../hooks/useAms';
import { Add, CogFilled, SignOut, SwitchArrowRight } from '../../icons';
import type { ThemableCssProp } from '../../styledSystem';
import type { DefaultItemIds, MenuItem } from '../../utils/createCustomMenuItems';
Expand Down Expand Up @@ -138,6 +139,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
} = props;

const { menutItems } = useUserButtonContext();
const ams = useAms();

const handleActionClick = async (route: MenuItem) => {
if (route?.path) {
Expand Down Expand Up @@ -172,18 +174,20 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
justify='between'
sx={t => ({ marginInlineStart: t.space.$12, padding: `0 ${t.space.$5} ${t.space.$4}`, gap: t.space.$2 })}
>
<SmallAction
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('manageAccount')}
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('manageAccount')}
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('manageAccount')}
icon={CogFilled}
label={localizationKeys('userButton.action__manageAccount')}
onClick={handleManageAccountClicked}
focusRing
/>
{!ams.isActive && (
<SmallAction
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('manageAccount')}
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('manageAccount')}
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('manageAccount')}
icon={CogFilled}
label={localizationKeys('userButton.action__manageAccount')}
onClick={handleManageAccountClicked}
focusRing
/>
)}
<SmallAction
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('signOut')}
Expand Down
15 changes: 13 additions & 2 deletions packages/ui/src/contexts/components/UserButton.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useClerk } from '@clerk/shared/react';
import { createContext, useContext, useMemo } from 'react';

import { createUserButtonCustomMenuItems } from '@/ui/utils/createCustomMenuItems';
import { USER_BUTTON_ITEM_ID } from '@/ui/constants';
import { useAms } from '@/ui/hooks/useAms';
import { createUserButtonCustomMenuItems, type MenuItem } from '@/ui/utils/createCustomMenuItems';

import { useEnvironment, useOptions } from '../../contexts';
import { useRouter } from '../../router';
Expand Down Expand Up @@ -39,6 +41,15 @@ export const useUserButtonContext = () => {
return createUserButtonCustomMenuItems(customMenuItems || [], clerk);
}, []);

// When the active session carries the `ams` claim, the "Manage account"
// entry would launch a <UserProfile/> modal whose underlying writes are
// rejected by the issuer. Strip it from the menu while the claim is
// active so users aren't presented with actions that can never succeed.
const ams = useAms();
const visibleMenuItems = ams.isActive
? menuItems.filter((item: MenuItem) => item.id !== USER_BUTTON_ITEM_ID.MANAGE_ACCOUNT)
: menuItems;

return {
...ctx,
componentName,
Expand All @@ -50,6 +61,6 @@ export const useUserButtonContext = () => {
afterSignOutUrl,
afterSwitchSessionUrl,
userProfileMode: userProfileMode || 'modal',
menutItems: menuItems,
menutItems: visibleMenuItems,
};
};
54 changes: 54 additions & 0 deletions packages/ui/src/hooks/__tests__/useAms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { JwtPayload } from '@clerk/shared/types';
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useAms } from '../useAms';

const { mockUseSession } = vi.hoisted(() => ({
mockUseSession: vi.fn(),
}));

vi.mock('@clerk/shared/react', () => ({
useSession: mockUseSession,
}));

const mockClaims = (claims: JwtPayload | undefined) => {
mockUseSession.mockReturnValue({
session: claims
? {
lastActiveToken: {
jwt: { claims },
},
}
: null,
});
};

describe('useAms', () => {
beforeEach(() => {
mockUseSession.mockReset();
});

it('returns inactive when the ams claim is absent', () => {
mockClaims({ __raw: 'token' } as JwtPayload);

const { result } = renderHook(() => useAms());

expect(result.current).toEqual({ isActive: false });
});

it('returns active when the ams claim is present without reading its value', () => {
const claims = { __raw: 'token' } as JwtPayload;
Object.defineProperty(claims, 'ams', {
enumerable: true,
get: () => {
throw new Error('ams should be opaque');
},
});
mockClaims(claims);

const { result } = renderHook(() => useAms());

expect(result.current).toEqual({ isActive: true });
});
});
1 change: 1 addition & 0 deletions packages/ui/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './useAms';
export * from './useClerkModalStateParams';
export * from './useClipboard';
export * from './useDebounce';
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/src/hooks/useAms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useSession } from '@clerk/shared/react';

/**
* Return shape for {@link useAms}. The claim is opaque, so callers should
* only branch on whether it is present.
*/
export type UseAmsReturn = {
isActive: boolean;
};

const INACTIVE: UseAmsReturn = {
isActive: false,
};

/**
* Returns information about the optional `ams` claim on the active
* session. The claim shape is intentionally opaque.
*
* Reactive — re-renders when the session token rotates.
*/
export const useAms = (): UseAmsReturn => {
const { session } = useSession();
const claims = session?.lastActiveToken?.jwt?.claims;

if (!claims || !Object.prototype.hasOwnProperty.call(claims, 'ams')) {
return INACTIVE;
}

return {
isActive: true,
};
};
Loading