Skip to content
Draft
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
27 changes: 27 additions & 0 deletions packages/shared/src/types/jwtv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ type JWTPayloadBase = {
*/
sts?: SessionStatusClaim;

/**
* Reserved session claim. When present, the session is operating under
* a restricted scope and SDK consumers should treat affected
* functionality as unavailable. The claim shape is opaque and may
* change without notice.
*/
ams?: AmsClaim;

/**
* Any other JWT Claim Set member.
*/
Expand Down Expand Up @@ -213,3 +221,22 @@ 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'>;

/**
* Shape of the optional `ams` session claim. Carries an identifier and a
* list of opaque scope strings that constrain what the session is
* permitted to do. Both fields should be treated as opaque tokens; their
* vocabulary is owned by the issuer and may change without notice.
*
* @inline
*/
export interface AmsClaim {
/**
* Opaque identifier the session is scoped to.
*/
app_id: string;
/**
* Opaque scope strings. Consumers should test for membership only.
*/
scopes: string[];
}
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,
};
};
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
55 changes: 55 additions & 0 deletions packages/ui/src/hooks/useAms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useSession } from '@clerk/shared/react';
import type { AmsClaim } from '@clerk/shared/types';

/**
* Return shape for {@link useAms}. Splits the "claim absent" case from
* the "claim present" case so callers can use `hasScope` unconditionally
* — when the claim is absent `hasScope` always returns `true`, meaning
* sites that read `if (!hasScope('foo')) hide()` keep working on
* regular sessions.
*/
export type UseAmsReturn =
| {
isActive: false;
appId: undefined;
scopes: undefined;
hasScope: () => true;
}
| {
isActive: true;
appId: string;
scopes: string[];
hasScope: (scope: string) => boolean;
};

const INACTIVE: UseAmsReturn = {
isActive: false,
appId: undefined,
scopes: undefined,
hasScope: () => true,
};

/**
* Returns information about the optional `ams` claim on the active
* session. When the claim is present the session is operating under a
* restricted scope; the returned `hasScope` helper can be used to gate
* UI on individual scope strings.
*
* Reactive — re-renders when the session token rotates.
*/
export const useAms = (): UseAmsReturn => {
const { session } = useSession();
const ams = session?.lastActiveToken?.jwt?.claims.ams as AmsClaim | undefined;

if (!ams || typeof ams.app_id !== 'string') {
return INACTIVE;
}

const scopes = Array.isArray(ams.scopes) ? ams.scopes : [];
return {
isActive: true,
appId: ams.app_id,
scopes,
hasScope: (scope: string) => scopes.includes(scope),
};
};
Loading