Repository navigation
feat(app): device sessions screen and owner session revoke - #147
Conversation
Settings gets "Thiết bị đang đăng nhập" for the signed-in user: the sessions Crove has seen, the current one marked from the access token's session_id, revoke one, sign out the other devices, and sign out everywhere. Revoking the current session signs this browser out and auth-js tells the other tabs. In the member list the owner can open "Thu hồi phiên" for a member and revoke one or all of their sessions in this org, with a confirmation and an optional reason; the copy explains it only blocks this org. STEP_UP_REQUIRED and 403 answers get clear Vietnamese messages. Closes #89 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.
Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 48 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces device session management, allowing users to view and revoke their own active sessions and organization owners to manage member sessions scoped to their organization. The feedback suggests improving the multi-session revocation logic by using Promise.allSettled instead of Promise.all to handle partial failures and avoid duplicate query invalidations. Additionally, it recommends expanding the user agent parser to correctly identify mobile Edge and Firefox on iOS, along with adding corresponding unit tests.
| const revokeOthers = useMutation({ | ||
| mutationFn: async (ids: string[]) => { | ||
| const results = await Promise.all(ids.map((sessionId) => client.sessions.revoke({ userId, sessionId }))); | ||
| return results.flatMap((r) => r.revoked); | ||
| }, | ||
| onSuccess: done, | ||
| onSettled: () => void queryClient.invalidateQueries({ queryKey: orpc.sessions.key() }), | ||
| }); |
There was a problem hiding this comment.
Using Promise.all to revoke multiple sessions means that if any single session revocation fails (e.g., due to a network timeout or if it was already revoked), the entire operation will reject, potentially leaving other sessions unrevoked. Additionally, calling done on success invalidates orpc.sessions.key(), but onSettled also invalidates the same key, causing duplicate query invalidations and redundant network requests. Using Promise.allSettled and removing onSettled resolves both issues.
| const revokeOthers = useMutation({ | |
| mutationFn: async (ids: string[]) => { | |
| const results = await Promise.all(ids.map((sessionId) => client.sessions.revoke({ userId, sessionId }))); | |
| return results.flatMap((r) => r.revoked); | |
| }, | |
| onSuccess: done, | |
| onSettled: () => void queryClient.invalidateQueries({ queryKey: orpc.sessions.key() }), | |
| }); | |
| const revokeOthers = useMutation({ | |
| mutationFn: async (ids: string[]) => { | |
| const results = await Promise.allSettled(ids.map((sessionId) => client.sessions.revoke({ userId, sessionId }))); | |
| return results | |
| .filter((r): r is PromiseFulfilledResult<{ revoked: string[] }> => r.status === 'fulfilled') | |
| .flatMap((r) => r.value.revoked); | |
| }, | |
| onSuccess: done, | |
| }); |
| const browser = /Edg\//.test(userAgent) | ||
| ? 'Edge' | ||
| : /OPR\/|Opera/.test(userAgent) | ||
| ? 'Opera' | ||
| : /Firefox\//.test(userAgent) | ||
| ? 'Firefox' | ||
| : /Chrome\/|CriOS\//.test(userAgent) | ||
| ? 'Chrome' | ||
| : /Safari\//.test(userAgent) | ||
| ? 'Safari' | ||
| : null; |
There was a problem hiding this comment.
The user agent parser does not detect Edge on mobile (EdgA/, EdgiOS/) or Firefox on iOS (FxiOS/). Because these user agents contain Safari/, they fall through and are incorrectly identified as Safari. Updating the regexes ensures mobile Edge and Firefox are correctly classified.
| const browser = /Edg\//.test(userAgent) | |
| ? 'Edge' | |
| : /OPR\/|Opera/.test(userAgent) | |
| ? 'Opera' | |
| : /Firefox\//.test(userAgent) | |
| ? 'Firefox' | |
| : /Chrome\/|CriOS\//.test(userAgent) | |
| ? 'Chrome' | |
| : /Safari\//.test(userAgent) | |
| ? 'Safari' | |
| : null; | |
| const browser = /Edg\/|EdgA\/|EdgiOS\//.test(userAgent) | |
| ? 'Edge' | |
| : /OPR\/|Opera/.test(userAgent) | |
| ? 'Opera' | |
| : /Firefox\/|FxiOS\//.test(userAgent) | |
| ? 'Firefox' | |
| : /Chrome\/|CriOS\//.test(userAgent) | |
| ? 'Chrome' | |
| : /Safari\//.test(userAgent) | |
| ? 'Safari' | |
| : null; |
| it('describes common browsers', () => { | ||
| expect( | ||
| describeUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'), | ||
| ).toBe('Chrome trên Windows'); | ||
| expect( | ||
| describeUserAgent( | ||
| 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0', | ||
| ), | ||
| ).toBe('Edge trên Windows'); | ||
| expect( | ||
| describeUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1'), | ||
| ).toBe('Safari trên iOS'); | ||
| expect(describeUserAgent('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')).toBe('Firefox trên Linux'); | ||
| expect(describeUserAgent('curl/8.0')).toBeNull(); | ||
| expect(describeUserAgent(null)).toBeNull(); | ||
| }); |
There was a problem hiding this comment.
Add unit tests for mobile Edge and Firefox on iOS to verify the updated user agent parser behaves correctly.
it('describes common browsers', () => {
expect(
describeUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'),
).toBe('Chrome trên Windows');
expect(
describeUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0',
),
).toBe('Edge trên Windows');
expect(
describeUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 EdgiOS/42.5.2.0 Mobile/16C50 Safari/605.1.15',
),
).toBe('Edge trên iOS');
expect(
describeUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1'),
).toBe('Safari trên iOS');
expect(
describeUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/13.2b9418 Mobile/16A366 Safari/605.1.15'),
).toBe('Firefox trên iOS');
expect(describeUserAgent('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')).toBe('Firefox trên Linux');
expect(describeUserAgent('curl/8.0')).toBeNull();
expect(describeUserAgent(null)).toBeNull();
});… step-up (#32) Rebased onto main with member invites, owner confirmations (#119), the sessions screen (#147, #178) and MFA_REQUIRED (#126), so the earlier commits of this PR are squashed into one. - auth-js uses PKCE. settleCallback() runs before the router: it explains failed links by known error codes only (a link's error_description is attacker-written), notes a link opened in another browser, sends recovery links to /reset-password, and removes the one-time code and error parameters from the URL and history. - /reset-password asks for the TOTP code first when the user has a factor; /mfa is the sign-in challenge, and every data route waits for aal2. MFA_REQUIRED from the kernel (#126) now sends the user to /mfa instead of showing an error; /mfa signs out through the shared sign-out that revokes the Crove session first (#178). - Settings: two-factor card (TOTP enroll, verify, disable). - Step-up (P11): approving, inviting, changing a role or removing a member (inside #119's confirmation) and rotating the inbound address show a prompt on STEP_UP_REQUIRED that names the pending action, asks for a fresh code or points to enabling two-factor, and repeats the refused request as sent (an approval prompted on one revision gets STALE_REVISION if someone saved another meanwhile). - Compose: GOTRUE_MAILER_URLPATHS_* include the /auth/v1 prefix (email links were broken on self-host), and AUTH_STEP_UP defaults to required. - e2e with email confirmation on (compose.e2e.yml, Mailpit, RFC 6238 TOTP, an inbound address for the rotation): confirmation and reused links, a forged link error, recovery, TOTP enrollment and the sign-in challenge, refresh and sign-out, the invite, owner confirmation, role change and removal step-up flows across two browsers, the rotation step-up, and the Inbox approval with its denied and aal2 audit rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
… step-up (#32) Rebased onto main with member invites, owner confirmations (#119), the sessions screen (#147, #178) and MFA_REQUIRED (#126), so the earlier commits of this PR are squashed into one. - auth-js uses PKCE. settleCallback() runs before the router: it explains failed links by known error codes only (a link's error_description is attacker-written), notes a link opened in another browser, sends recovery links to /reset-password, and removes the one-time code and error parameters from the URL and history. - /reset-password asks for the TOTP code first when the user has a factor; /mfa is the sign-in challenge, and every data route waits for aal2. MFA_REQUIRED from the kernel (#126) now sends the user to /mfa instead of showing an error; /mfa signs out through the shared sign-out that revokes the Crove session first (#178). - Settings: two-factor card (TOTP enroll, verify, disable). - Step-up (P11): approving, inviting, changing a role or removing a member (inside #119's confirmation) and rotating the inbound address show a prompt on STEP_UP_REQUIRED that names the pending action, asks for a fresh code or points to enabling two-factor, and repeats the refused request as sent (an approval prompted on one revision gets STALE_REVISION if someone saved another meanwhile). - Compose: GOTRUE_MAILER_URLPATHS_* include the /auth/v1 prefix (email links were broken on self-host), and AUTH_STEP_UP defaults to required. - e2e with email confirmation on (compose.e2e.yml, Mailpit, RFC 6238 TOTP, an inbound address for the rotation): confirmation and reused links, a forged link error, recovery, TOTP enrollment and the sign-in challenge, refresh and sign-out, the invite, owner confirmation, role change and removal step-up flows across two browsers, the rotation step-up, and the Inbox approval with its denied and aal2 audit rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
… step-up (#32) Rebased onto main with member invites, owner confirmations (#119), the sessions screen (#147, #178) and MFA_REQUIRED (#126), so the earlier commits of this PR are squashed into one. - auth-js uses PKCE. settleCallback() runs before the router: it explains failed links by known error codes only (a link's error_description is attacker-written), notes a link opened in another browser, sends recovery links to /reset-password, and removes the one-time code and error parameters from the URL and history. - /reset-password asks for the TOTP code first when the user has a factor; /mfa is the sign-in challenge, and every data route waits for aal2. MFA_REQUIRED from the kernel (#126) now sends the user to /mfa instead of showing an error; /mfa signs out through the shared sign-out that revokes the Crove session first (#178). - Settings: two-factor card (TOTP enroll, verify, disable). - Step-up (P11): approving, inviting, changing a role or removing a member (inside #119's confirmation) and rotating the inbound address show a prompt on STEP_UP_REQUIRED that names the pending action, asks for a fresh code or points to enabling two-factor, and repeats the refused request as sent (an approval prompted on one revision gets STALE_REVISION if someone saved another meanwhile). - Compose: GOTRUE_MAILER_URLPATHS_* include the /auth/v1 prefix (email links were broken on self-host), and AUTH_STEP_UP defaults to required. - e2e with email confirmation on (compose.e2e.yml, Mailpit, RFC 6238 TOTP, an inbound address for the rotation): confirmation and reused links, a forged link error, recovery, TOTP enrollment and the sign-in challenge, refresh and sign-out, the invite, owner confirmation, role change and removal step-up flows across two browsers, the rotation step-up, and the Inbox approval with its denied and aal2 audit rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
… step-up (#32) Rebased onto main with member invites, owner confirmations (#119), the sessions screen (#147, #178) and MFA_REQUIRED (#126), so the earlier commits of this PR are squashed into one. - auth-js uses PKCE. settleCallback() runs before the router: it explains failed links by known error codes only (a link's error_description is attacker-written), notes a link opened in another browser, sends recovery links to /reset-password, and removes the one-time code and error parameters from the URL and history. - /reset-password asks for the TOTP code first when the user has a factor; /mfa is the sign-in challenge, and every data route waits for aal2. MFA_REQUIRED from the kernel (#126) now sends the user to /mfa instead of showing an error; /mfa signs out through the shared sign-out that revokes the Crove session first (#178). - Settings: two-factor card (TOTP enroll, verify, disable). - Step-up (P11): approving, inviting, changing a role or removing a member (inside #119's confirmation) and rotating the inbound address show a prompt on STEP_UP_REQUIRED that names the pending action, asks for a fresh code or points to enabling two-factor, and repeats the refused request as sent (an approval prompted on one revision gets STALE_REVISION if someone saved another meanwhile). - Compose: GOTRUE_MAILER_URLPATHS_* include the /auth/v1 prefix (email links were broken on self-host), and AUTH_STEP_UP defaults to required. - e2e with email confirmation on (compose.e2e.yml, Mailpit, RFC 6238 TOTP, an inbound address for the rotation): confirmation and reused links, a forged link error, recovery, TOTP enrollment and the sign-in challenge, refresh and sign-out, the invite, owner confirmation, role change and removal step-up flows across two browsers, the rotation step-up, and the Inbox approval with its denied and aal2 audit rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
… step-up (#32) Rebased onto main with member invites, owner confirmations (#119), the sessions screen (#147, #178) and MFA_REQUIRED (#126), so the earlier commits of this PR are squashed into one. - auth-js uses PKCE. settleCallback() runs before the router: it explains failed links by known error codes only (a link's error_description is attacker-written), notes a link opened in another browser, sends recovery links to /reset-password, and removes the one-time code and error parameters from the URL and history. - /reset-password asks for the TOTP code first when the user has a factor; /mfa is the sign-in challenge, and every data route waits for aal2. MFA_REQUIRED from the kernel (#126) now sends the user to /mfa instead of showing an error; /mfa signs out through the shared sign-out that revokes the Crove session first (#178). - Settings: two-factor card (TOTP enroll, verify, disable). - Step-up (P11): approving, inviting, changing a role or removing a member (inside #119's confirmation) and rotating the inbound address show a prompt on STEP_UP_REQUIRED that names the pending action, asks for a fresh code or points to enabling two-factor, and repeats the refused request as sent (an approval prompted on one revision gets STALE_REVISION if someone saved another meanwhile). - Compose: GOTRUE_MAILER_URLPATHS_* include the /auth/v1 prefix (email links were broken on self-host), and AUTH_STEP_UP defaults to required. - e2e with email confirmation on (compose.e2e.yml, Mailpit, RFC 6238 TOTP, an inbound address for the rotation): confirmation and reused links, a forged link error, recovery, TOTP enrollment and the sign-in challenge, refresh and sign-out, the invite, owner confirmation, role change and removal step-up flows across two browsers, the rotation step-up, and the Inbox approval with its denied and aal2 audit rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
… step-up (#32) Rebased onto main with member invites, owner confirmations (#119), the sessions screen (#147, #178) and MFA_REQUIRED (#126), so the earlier commits of this PR are squashed into one. - auth-js uses PKCE. settleCallback() runs before the router: it explains failed links by known error codes only (a link's error_description is attacker-written), notes a link opened in another browser, sends recovery links to /reset-password, and removes the one-time code and error parameters from the URL and history. - /reset-password asks for the TOTP code first when the user has a factor; /mfa is the sign-in challenge, and every data route waits for aal2. MFA_REQUIRED from the kernel (#126) now sends the user to /mfa instead of showing an error; /mfa signs out through the shared sign-out that revokes the Crove session first (#178). - Settings: two-factor card (TOTP enroll, verify, disable). - Step-up (P11): approving, inviting, changing a role or removing a member (inside #119's confirmation) and rotating the inbound address show a prompt on STEP_UP_REQUIRED that names the pending action, asks for a fresh code or points to enabling two-factor, and repeats the refused request as sent (an approval prompted on one revision gets STALE_REVISION if someone saved another meanwhile). - Compose: GOTRUE_MAILER_URLPATHS_* include the /auth/v1 prefix (email links were broken on self-host), and AUTH_STEP_UP defaults to required. - e2e with email confirmation on (compose.e2e.yml, Mailpit, RFC 6238 TOTP, an inbound address for the rotation): confirmation and reused links, a forged link error, recovery, TOTP enrollment and the sign-in challenge, refresh and sign-out, the invite, owner confirmation, role change and removal step-up flows across two browsers, the rotation step-up, and the Inbox approval with its denied and aal2 audit rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
… step-up (#32) Rebased onto main with member invites, owner confirmations (#119), the sessions screen (#147, #178) and MFA_REQUIRED (#126), so the earlier commits of this PR are squashed into one. - auth-js uses PKCE. settleCallback() runs before the router: it explains failed links by known error codes only (a link's error_description is attacker-written), notes a link opened in another browser, sends recovery links to /reset-password, and removes the one-time code and error parameters from the URL and history. - /reset-password asks for the TOTP code first when the user has a factor; /mfa is the sign-in challenge, and every data route waits for aal2. MFA_REQUIRED from the kernel (#126) now sends the user to /mfa instead of showing an error; /mfa signs out through the shared sign-out that revokes the Crove session first (#178). - Settings: two-factor card (TOTP enroll, verify, disable). - Step-up (P11): approving, inviting, changing a role or removing a member (inside #119's confirmation) and rotating the inbound address show a prompt on STEP_UP_REQUIRED that names the pending action, asks for a fresh code or points to enabling two-factor, and repeats the refused request as sent (an approval prompted on one revision gets STALE_REVISION if someone saved another meanwhile). - Compose: GOTRUE_MAILER_URLPATHS_* include the /auth/v1 prefix (email links were broken on self-host), and AUTH_STEP_UP defaults to required. - e2e with email confirmation on (compose.e2e.yml, Mailpit, RFC 6238 TOTP, an inbound address for the rotation): confirmation and reused links, a forged link error, recovery, TOTP enrollment and the sign-in challenge, refresh and sign-out, the invite, owner confirmation, role change and removal step-up flows across two browsers, the rotation step-up, and the Inbox approval with its denied and aal2 audit rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
Closes #89
What
session_id(this browser also gets a "Chrome trên Windows" style label; the API records no user agent, so other sessions show a short id). Revoke one, "Đăng xuất các thiết bị khác", and "Đăng xuất khỏi mọi nơi" (all: true), each with an inline confirmation. Revoking the current session, or everything, signs this browser out locally; auth-js broadcasts SIGNED_OUT so other tabs go to /login too. Revoked sessions sit in a collapsed "Đã thu hồi (n)" list.STEP_UP_REQUIREDand 403 answers get clear Vietnamese messages (admins are told only the owner may do this). Revoke is R1 today; the step-up message is there in case a deployment tightens it.Files
New:
apps/app/src/lib/sessions.ts(pure helpers),apps/app/src/components/sessions.tsx,apps/app/test/sessions.test.ts.settings.tsxonly gets the wiring (kept small because #101 also edits it). No protected paths.Tests
session_iddecoding (base64url, malformed tokens), sorting, other-session ids, labels, error messages.mainyet (test: Playwright end-to-end test of the Inbox flow on docker compose #91 is open), so no e2e spec. Checked in a browser against the built SPA with mocked API answers: owner revoke with reason (request carriesreasonandsessionId), STEP_UP_REQUIRED message, 403 message, revoke other devices, revoke current session and sign out everywhere (both tabs land on /login, local session cleared).pnpm lint,pnpm typecheck,pnpm test,pnpm --filter @crove/app buildpass.🤖 Generated with Claude Code
📌 TL;DR
Implements the "Device Sessions" feature (ADR-0028) allowing users to view and revoke their own active sessions, and enabling organization owners to revoke member sessions within a specific org. Includes pure utility functions for session parsing/sorting and comprehensive unit tests.
🎯 Type of Change
🔍 Changes Walkthrough
apps/app/src/components/sessions.tsxMySessions(user's own device list with revoke/sign-out actions) andMemberSessions(owner-only panel to revoke a member's org-scoped sessions). Handles confirmation dialogs, error states, and local sign-out logic.apps/app/src/lib/sessions.tsSessionRowtype, JWTsession_idextraction, session sorting logic, user-agent parsing for display labels, and error message mapping for specific API error codes (e.g.,STEP_UP_REQUIRED).apps/app/src/routes/settings.tsxMySessionscard at the bottom and embedsMemberSessionstoggle/panel within the organization member list for owners.apps/app/test/sessions.test.tssessions.tsutilities, covering JWT parsing edge cases, sorting order, user-agent detection, and error message formatting.📊 Architectural Flow
sequenceDiagram participant UI as Settings Page participant Comp as Session Components participant Lib as Session Utils participant API as oRPC Client participant Server as Backend Note over UI,Comp: User views Settings UI->>Comp: Render MySessions / MemberSessions Comp->>API: Query sessions.list API->>Server: GET /sessions Server-->>API: Session List API-->>Comp: Data Comp->>Lib: sortSessions, describeUserAgent Lib-->>Comp: Formatted Data Comp-->>UI: Render Session List Note over UI,Server: User initiates Revoke UI->>Comp: Click "Revoke" Comp->>Comp: Show ConfirmBox UI->>Comp: Confirm Action Comp->>API: Mutation sessions.revoke API->>Server: POST /sessions/revoke Server-->>API: Success / Error API-->>Comp: Result alt Current Session Revoked Comp->>Comp: signOutHere() else Other Session Comp->>API: Invalidate Queries end Comp-->>UI: Update UI State