Skip to content

Commit 2be4cbf

Browse files
committed
feat(ui): Introduce ams claim handling in UserButton context and components
- Added `ams` claim type to JWT payload for session management. - Integrated `useAms` hook to conditionally render the "Manage account" action in the UserButton based on the active session's `ams` claim. - Updated UserButton context to filter out the "Manage account" menu item when the `ams` claim is active, preventing users from accessing unavailable actions.
1 parent 00f9ff9 commit 2be4cbf

5 files changed

Lines changed: 112 additions & 14 deletions

File tree

packages/shared/src/types/jwtv2.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ type JWTPayloadBase = {
9797
*/
9898
sts?: SessionStatusClaim;
9999

100+
/**
101+
* Reserved session claim. When present, the session is operating under
102+
* a restricted scope and SDK consumers should treat affected
103+
* functionality as unavailable. The claim shape is opaque and may
104+
* change without notice.
105+
*/
106+
ams?: AmsClaim;
107+
100108
/**
101109
* Any other JWT Claim Set member.
102110
*/
@@ -213,3 +221,22 @@ export type AgentActClaim = ActClaim & { type: 'agent' };
213221
* The current state of the session which can only be `active` or `pending`.
214222
*/
215223
export type SessionStatusClaim = Extract<SessionStatus, 'active' | 'pending'>;
224+
225+
/**
226+
* Shape of the optional `ams` session claim. Carries an identifier and a
227+
* list of opaque scope strings that constrain what the session is
228+
* permitted to do. Both fields should be treated as opaque tokens; their
229+
* vocabulary is owned by the issuer and may change without notice.
230+
*
231+
* @inline
232+
*/
233+
export interface AmsClaim {
234+
/**
235+
* Opaque identifier the session is scoped to.
236+
*/
237+
app_id: string;
238+
/**
239+
* Opaque scope strings. Consumers should test for membership only.
240+
*/
241+
scopes: string[];
242+
}

packages/ui/src/components/UserButton/SessionActions.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { USER_BUTTON_ITEM_ID } from '../../constants';
1010
import { useUserButtonContext } from '../../contexts';
1111
import type { LocalizationKey } from '../../customizables';
1212
import { descriptors, Flex, localizationKeys } from '../../customizables';
13+
import { useAms } from '../../hooks/useAms';
1314
import { Add, CogFilled, SignOut, SwitchArrowRight } from '../../icons';
1415
import type { ThemableCssProp } from '../../styledSystem';
1516
import type { DefaultItemIds, MenuItem } from '../../utils/createCustomMenuItems';
@@ -138,6 +139,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
138139
} = props;
139140

140141
const { menutItems } = useUserButtonContext();
142+
const ams = useAms();
141143

142144
const handleActionClick = async (route: MenuItem) => {
143145
if (route?.path) {
@@ -172,18 +174,20 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => {
172174
justify='between'
173175
sx={t => ({ marginInlineStart: t.space.$12, padding: `0 ${t.space.$5} ${t.space.$4}`, gap: t.space.$2 })}
174176
>
175-
<SmallAction
176-
elementDescriptor={descriptors.userButtonPopoverActionButton}
177-
elementId={descriptors.userButtonPopoverActionButton.setId('manageAccount')}
178-
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
179-
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('manageAccount')}
180-
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
181-
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('manageAccount')}
182-
icon={CogFilled}
183-
label={localizationKeys('userButton.action__manageAccount')}
184-
onClick={handleManageAccountClicked}
185-
focusRing
186-
/>
177+
{!ams.isActive && (
178+
<SmallAction
179+
elementDescriptor={descriptors.userButtonPopoverActionButton}
180+
elementId={descriptors.userButtonPopoverActionButton.setId('manageAccount')}
181+
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
182+
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('manageAccount')}
183+
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
184+
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('manageAccount')}
185+
icon={CogFilled}
186+
label={localizationKeys('userButton.action__manageAccount')}
187+
onClick={handleManageAccountClicked}
188+
focusRing
189+
/>
190+
)}
187191
<SmallAction
188192
elementDescriptor={descriptors.userButtonPopoverActionButton}
189193
elementId={descriptors.userButtonPopoverActionButton.setId('signOut')}

packages/ui/src/contexts/components/UserButton.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { useClerk } from '@clerk/shared/react';
22
import { createContext, useContext, useMemo } from 'react';
33

4-
import { createUserButtonCustomMenuItems } from '@/ui/utils/createCustomMenuItems';
4+
import { USER_BUTTON_ITEM_ID } from '@/ui/constants';
5+
import { useAms } from '@/ui/hooks/useAms';
6+
import { createUserButtonCustomMenuItems, type MenuItem } from '@/ui/utils/createCustomMenuItems';
57

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

44+
// When the active session carries the `ams` claim, the "Manage account"
45+
// entry would launch a <UserProfile/> modal whose underlying writes are
46+
// rejected by the issuer. Strip it from the menu while the claim is
47+
// active so users aren't presented with actions that can never succeed.
48+
const ams = useAms();
49+
const visibleMenuItems = ams.isActive
50+
? menuItems.filter((item: MenuItem) => item.id !== USER_BUTTON_ITEM_ID.MANAGE_ACCOUNT)
51+
: menuItems;
52+
4253
return {
4354
...ctx,
4455
componentName,
@@ -50,6 +61,6 @@ export const useUserButtonContext = () => {
5061
afterSignOutUrl,
5162
afterSwitchSessionUrl,
5263
userProfileMode: userProfileMode || 'modal',
53-
menutItems: menuItems,
64+
menutItems: visibleMenuItems,
5465
};
5566
};

packages/ui/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './useAms';
12
export * from './useClerkModalStateParams';
23
export * from './useClipboard';
34
export * from './useDebounce';

packages/ui/src/hooks/useAms.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useSession } from '@clerk/shared/react';
2+
import type { AmsClaim } from '@clerk/shared/types';
3+
4+
/**
5+
* Return shape for {@link useAms}. Splits the "claim absent" case from
6+
* the "claim present" case so callers can use `hasScope` unconditionally
7+
* — when the claim is absent `hasScope` always returns `true`, meaning
8+
* sites that read `if (!hasScope('foo')) hide()` keep working on
9+
* regular sessions.
10+
*/
11+
export type UseAmsReturn =
12+
| {
13+
isActive: false;
14+
appId: undefined;
15+
scopes: undefined;
16+
hasScope: () => true;
17+
}
18+
| {
19+
isActive: true;
20+
appId: string;
21+
scopes: string[];
22+
hasScope: (scope: string) => boolean;
23+
};
24+
25+
const INACTIVE: UseAmsReturn = {
26+
isActive: false,
27+
appId: undefined,
28+
scopes: undefined,
29+
hasScope: () => true,
30+
};
31+
32+
/**
33+
* Returns information about the optional `ams` claim on the active
34+
* session. When the claim is present the session is operating under a
35+
* restricted scope; the returned `hasScope` helper can be used to gate
36+
* UI on individual scope strings.
37+
*
38+
* Reactive — re-renders when the session token rotates.
39+
*/
40+
export const useAms = (): UseAmsReturn => {
41+
const { session } = useSession();
42+
const ams = session?.lastActiveToken?.jwt?.claims.ams as AmsClaim | undefined;
43+
44+
if (!ams || typeof ams.app_id !== 'string') {
45+
return INACTIVE;
46+
}
47+
48+
const scopes = Array.isArray(ams.scopes) ? ams.scopes : [];
49+
return {
50+
isActive: true,
51+
appId: ams.app_id,
52+
scopes,
53+
hasScope: (scope: string) => scopes.includes(scope),
54+
};
55+
};

0 commit comments

Comments
 (0)