@@ -3,6 +3,7 @@ import type {
33 OrganizationMembershipJSON ,
44 OrganizationSuggestionJSON ,
55 SessionJSON ,
6+ SessionWithActivitiesJSON ,
67 UserOrganizationInvitationJSON ,
78} from '@clerk/shared/types' ;
89import { http , HttpResponse , type JsonBodyType } from 'msw' ;
@@ -37,6 +38,16 @@ export interface FakeFapiState {
3738 suggestions : OrganizationSuggestionJSON [ ] ;
3839 verification : FakeVerificationState ;
3940 passwordUpdates : URLSearchParams [ ] ;
41+ activeDevices ?: ActiveDeviceRecord [ ] ;
42+ deviceTrackingEnabled : boolean ;
43+ }
44+
45+ export interface ActiveDeviceRecord extends SessionWithActivitiesJSON {
46+ ownerUserId : string ;
47+ replacementSessionId ?: string ;
48+ inactivityTimeoutSeconds ?: number ;
49+ touchedAt ?: number ;
50+ tokenIssuedAt ?: number ;
4051}
4152
4253export type FakeFapiSeed = Partial < Omit < FakeFapiState , 'verification' > > & {
@@ -81,10 +92,40 @@ function findSession(state: FakeFapiState, id: unknown): SessionJSON | undefined
8192 return state . client . sessions . find ( session => session . id === id ) ;
8293}
8394
95+ function requestingSession ( state : FakeFapiState , request : Request ) : SessionJSON | undefined {
96+ const sessionId = new URL ( request . url ) . searchParams . get ( '_clerk_session_id' ) ?? state . client . last_active_session_id ;
97+ return findSession ( state , sessionId ) ;
98+ }
99+
84100function missing ( ) {
85101 return HttpResponse . json ( { errors : [ { code : 'resource_not_found' , message : 'not found' } ] } , { status : 404 } ) ;
86102}
87103
104+ function canRevokeDevice ( record : ActiveDeviceRecord ) : boolean {
105+ const now = Date . now ( ) ;
106+ const lastActivity = Math . max ( record . touchedAt ?? record . last_active_at , record . tokenIssuedAt ?? 0 ) ;
107+ return (
108+ record . status === 'active' &&
109+ ! record . replacementSessionId &&
110+ record . expire_at > now &&
111+ record . abandon_at > now &&
112+ ( ! record . inactivityTimeoutSeconds || now - lastActivity <= record . inactivityTimeoutSeconds * 1000 )
113+ ) ;
114+ }
115+
116+ function deviceSessionPayload ( record : ActiveDeviceRecord ) {
117+ const {
118+ ownerUserId : _ownerUserId ,
119+ replacementSessionId : _replacementSessionId ,
120+ inactivityTimeoutSeconds : _inactivityTimeoutSeconds ,
121+ touchedAt : _touchedAt ,
122+ tokenIssuedAt : _tokenIssuedAt ,
123+ latest_activity : _latestActivity ,
124+ ...session
125+ } = record ;
126+ return session ;
127+ }
128+
88129export function serveFapi ( seed : FakeFapiSeed = { } ) : FakeFapiState {
89130 const { verification, ...rest } = seed ;
90131 const state : FakeFapiState = {
@@ -94,6 +135,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
94135 invitations : [ ] ,
95136 suggestions : [ ] ,
96137 passwordUpdates : [ ] ,
138+ deviceTrackingEnabled : true ,
97139 ...rest ,
98140 verification : createVerificationState ( verification ) ,
99141 } ;
@@ -138,6 +180,53 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
138180 state . client = { ...state . client , sessions, last_active_session_id : sessions [ 0 ] ?. id ?? null } ;
139181 return envelope ( { ...session , status : 'removed' } , state . client ) ;
140182 } ) ,
183+ http . get ( fapiUrl ( '/v1/me/sessions/active' ) , ( { request } ) => {
184+ if ( ! state . activeDevices ) {
185+ return undefined ;
186+ }
187+ const requester = requestingSession ( state , request ) ;
188+ if ( ! requester ) {
189+ return missing ( ) ;
190+ }
191+ if ( ! state . deviceTrackingEnabled ) {
192+ const { user : _user , ...session } = requester ;
193+ return HttpResponse . json ( [ { ...session , user : null } ] ) ;
194+ }
195+ const records = state . activeDevices . filter (
196+ item => item . ownerUserId === requester . user . id && canRevokeDevice ( item ) && ( ! item . actor || requester . actor ) ,
197+ ) ;
198+ return HttpResponse . json (
199+ records . map ( item => ( {
200+ ...deviceSessionPayload ( item ) ,
201+ status : item . tasks ?. length ? 'pending' : item . status ,
202+ latest_activity : item . latest_activity ,
203+ } ) ) ,
204+ ) ;
205+ } ) ,
206+ http . post ( fapiUrl ( '/v1/me/sessions/:id/revoke' ) , ( { params, request } ) => {
207+ if ( ! state . activeDevices ) {
208+ return undefined ;
209+ }
210+ const requester = requestingSession ( state , request ) ;
211+ if ( ! requester ) {
212+ return missing ( ) ;
213+ }
214+ const target = state . activeDevices . find ( item => item . id === params . id ) ;
215+ if ( target ?. id === requester . id ) {
216+ const code = 'invalid_action_for_session' ;
217+ return HttpResponse . json ( { errors : [ { code, message : code } ] } , { status : 400 } ) ;
218+ }
219+ if ( ! target || target . ownerUserId !== requester . user . id ) {
220+ const code = 'action_for_session_not_authorized' ;
221+ return HttpResponse . json ( { errors : [ { code, message : code } ] } , { status : 401 } ) ;
222+ }
223+ if ( ! canRevokeDevice ( target ) ) {
224+ const code = 'invalid_action_for_session' ;
225+ return HttpResponse . json ( { errors : [ { code, message : code } ] } , { status : 400 } ) ;
226+ }
227+ target . status = 'revoked' ;
228+ return envelope ( { ...deviceSessionPayload ( target ) , user : requester . user , status : 'revoked' } , state . client ) ;
229+ } ) ,
141230 http . post ( fapiUrl ( '/v1/me/change_password' ) , async ( { request } ) => {
142231 const session = findSession ( state , state . client . last_active_session_id ) ;
143232 if ( ! session ) {
0 commit comments