feat(account): add session management as standalone Sessions page#8961
feat(account): add session management as standalone Sessions page#8961wangsijie wants to merge 10 commits into
Conversation
Add SessionSection component to the account center Security page, allowing users to view active sessions and revoke non-current ones. Changes: - Add UserScope.Sessions to OIDC scopes in App.tsx - Create apis/sessions.ts with getSessions and revokeSession - Create SessionSection component with session list, current session badge, revoke confirmation modal, and identity verification flow - Add session field check to hasVisibleSecuritySection - Add 'load-sessions' to pending verified actions - Add i18n phrases for session management across all locales
…ement - Replace custom UA parser with ua-parser-js (consistent with Console) - Use userSessionSignInContextGuard.safeParse for safe context parsing - Adopt grid-based row layout matching SocialSection pattern - Show device name, location, IP, and sign-in time per session - Use danger-colored revoke button (consistent with Console conventions) - Add responsive mobile layout with mixin pattern
- Extract SessionSection from Security page into standalone /sessions route - Split into two sections: Sessions and Third-party grants (aligned with Console user management) - Add grants API (getGrants, revokeGrant) - Add navigation support (sidebar, mobile tab) - Fix PR comments: hasLoaded on error, double-parse UA, timestamp handling - Add i18n keys with UNTRANSLATED markers for non-English locales
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
COMPARE TO
|
| Name | Diff |
|---|---|
| packages/account/src/App.tsx | 📈 +445 Bytes |
| packages/account/src/apis/sessions.ts | 📈 +1.57 KB |
| packages/account/src/assets/icons/sessions.svg | 📈 +470 Bytes |
| packages/account/src/components/MobileTabNav/index.test.tsx | 📈 +63 Bytes |
| packages/account/src/components/account-nav-items.test.ts | 📈 +147 Bytes |
| packages/account/src/components/account-nav-items.ts | 📈 +366 Bytes |
| packages/account/src/constants/routes.ts | 📈 +42 Bytes |
| packages/account/src/jest.setup.ts | 📈 +95 Bytes |
| packages/account/src/pages/Security/index.test.tsx | 📈 +52 Bytes |
| packages/account/src/pages/Sessions/GrantRow.tsx | 📈 +1.27 KB |
| packages/account/src/pages/Sessions/SessionRow.tsx | 📈 +1.79 KB |
| packages/account/src/pages/Sessions/index.module.scss | 📈 +2.84 KB |
| packages/account/src/pages/Sessions/index.tsx | |
| packages/account/src/pages/Sessions/utils.ts | 📈 +3.49 KB |
| packages/account/src/pages/VerifiedAction/index.tsx | 📈 +303 Bytes |
| packages/account/src/utils/security-page.ts | 📈 +314 Bytes |
| packages/account/src/utils/session-storage.ts | 📈 +19 Bytes |
| packages/phrases-experience/src/locales/ar/account-center.ts | 📈 +1.41 KB |
| packages/phrases-experience/src/locales/cs/account-center.ts | 📈 +1.07 KB |
| packages/phrases-experience/src/locales/de/account-center.ts | 📈 +1.12 KB |
| packages/phrases-experience/src/locales/en/account-center.ts | 📈 +1023 Bytes |
| packages/phrases-experience/src/locales/es/account-center.ts | 📈 +1.08 KB |
| packages/phrases-experience/src/locales/fr/account-center.ts | 📈 +1.08 KB |
| packages/phrases-experience/src/locales/it/account-center.ts | 📈 +1.08 KB |
| packages/phrases-experience/src/locales/ja/account-center.ts | 📈 +1.34 KB |
| packages/phrases-experience/src/locales/ko/account-center.ts | 📈 +1.09 KB |
| packages/phrases-experience/src/locales/pl-pl/account-center.ts | 📈 +1.08 KB |
| packages/phrases-experience/src/locales/pt-br/account-center.ts | 📈 +1.03 KB |
| packages/phrases-experience/src/locales/pt-pt/account-center.ts | 📈 +1.08 KB |
| packages/phrases-experience/src/locales/ru/account-center.ts | 📈 +1.49 KB |
| packages/phrases-experience/src/locales/th/account-center.ts | 📈 +2.06 KB |
| packages/phrases-experience/src/locales/tr-tr/account-center.ts | 📈 +1.12 KB |
| packages/phrases-experience/src/locales/uk-ua/account-center.ts | 📈 +1.44 KB |
| packages/phrases-experience/src/locales/zh-cn/account-center.ts | 📈 +949 Bytes |
| packages/phrases-experience/src/locales/zh-hk/account-center.ts | 📈 +1 KB |
| packages/phrases-experience/src/locales/zh-tw/account-center.ts | 📈 +1 KB |
There was a problem hiding this comment.
Pull request overview
Adds a new standalone Sessions page to the Account Center (/sessions) to let end users view/revoke active sessions and manage third‑party application grants, while integrating with the existing verification flow and navigation gating via Account Center settings.
Changes:
- Introduces a new
/sessionsroute + sidebar/nav item gated byaccountCenter.fields.session. - Implements Sessions UI (sessions list + third‑party grants list) with revoke actions and verification pending action (
load-sessions). - Adds full i18n coverage for Sessions strings across the provided locales.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/phrases-experience/src/locales/ar/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Arabic). |
| packages/phrases-experience/src/locales/cs/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Czech). |
| packages/phrases-experience/src/locales/de/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (German). |
| packages/phrases-experience/src/locales/en/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (English). |
| packages/phrases-experience/src/locales/es/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Spanish). |
| packages/phrases-experience/src/locales/fr/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (French). |
| packages/phrases-experience/src/locales/it/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Italian). |
| packages/phrases-experience/src/locales/ja/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Japanese). |
| packages/phrases-experience/src/locales/ko/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Korean). |
| packages/phrases-experience/src/locales/pl-pl/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Polish). |
| packages/phrases-experience/src/locales/pt-br/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Portuguese BR). |
| packages/phrases-experience/src/locales/pt-pt/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Portuguese PT). |
| packages/phrases-experience/src/locales/ru/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Russian). |
| packages/phrases-experience/src/locales/th/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Thai). |
| packages/phrases-experience/src/locales/tr-tr/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Turkish). |
| packages/phrases-experience/src/locales/uk-ua/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Ukrainian). |
| packages/phrases-experience/src/locales/zh-cn/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Simplified Chinese). |
| packages/phrases-experience/src/locales/zh-hk/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Traditional Chinese, HK). |
| packages/phrases-experience/src/locales/zh-tw/account-center.ts | Adds Sessions sidebar label + Sessions/grants strings (Traditional Chinese, TW). |
| packages/account/src/utils/session-storage.ts | Adds load-sessions to the pending verified action union. |
| packages/account/src/utils/security-page.ts | Adds hasVisibleSessionsPage() gating for route/nav visibility. |
| packages/account/src/pages/VerifiedAction/index.tsx | Allows the verification flow when pending action is load-sessions. |
| packages/account/src/pages/Sessions/utils.ts | Adds UA parsing + timestamp formatting + grant grouping utilities. |
| packages/account/src/pages/Sessions/SessionRow.tsx | Renders a session row, including “Current session” badge and revoke button. |
| packages/account/src/pages/Sessions/GrantRow.tsx | Renders a third‑party app grant group row with revoke action. |
| packages/account/src/pages/Sessions/index.tsx | Implements the Sessions page data loading, verification integration, and revoke flows. |
| packages/account/src/pages/Sessions/index.module.scss | Styles for Sessions page rows/cards and mobile layout. |
| packages/account/src/pages/Security/index.test.tsx | Updates Security test setup to include session field. |
| packages/account/src/constants/routes.ts | Adds sessionsRoute constant. |
| packages/account/src/components/MobileTabNav/index.test.tsx | Updates nav item builder usage to include hasSessions. |
| packages/account/src/components/account-nav-items.ts | Adds Sessions nav item and icon wiring. |
| packages/account/src/components/account-nav-items.test.ts | Updates/expands tests for sessions nav item. |
| packages/account/src/assets/icons/sessions.svg | Adds Sessions icon SVG. |
| packages/account/src/App.tsx | Registers /sessions route, nav visibility, full-page layout logic, and Sessions scope. |
| packages/account/src/apis/sessions.ts | Adds Account Center APIs for sessions/grants list + revocation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Test Report: Standalone Sessions PageAll 4 tests passed. Tested locally with the refactored Sessions page. Test Results
ScreenshotsSessions Page (Pre-verification)Standalone page at Sessions Page (Post-verification)After password verification, sessions list and Third-party apps section load correctly. Field Off → Page HiddenSetting Security Page (No SessionSection)Security page only shows Password section — SessionSection fully removed. Final Sessions Page ViewComplete view with both sections: Sessions (current session with metadata) and Third-party apps (empty state). Not Fully Tested in Dev
|
…atures - Sort grant rows by numeric iat instead of locale-formatted string - Only remove grant row from UI when all revocations succeed - Handle grant fetch errors via handleError - Gate grants section on grantRows !== undefined - Gate Sessions page behind isDevFeaturesEnabled
| return ( | ||
| <div className={classNames(styles.row, layoutClassNames.row)}> | ||
| <div className={styles.sessionInfo}> | ||
| <div className={styles.deviceName}>{app.applicationId}</div> |
There was a problem hiding this comment.
This renders app.applicationId (the raw OIDC clientId, an opaque ID) as the app name. The Console solves the same screen with <ApplicationName applicationId={…} /> (packages/console/.../UserSessionDetails), which resolves the human-readable name and handles deleted/system-app fallbacks. As-is, end users see authorized apps as bare client IDs and can't tell what they're revoking. Since this is end-user-facing, worth resolving the app name (likely needs a my-account-scoped applications lookup or an ApplicationName equivalent).
There was a problem hiding this comment.
Follow-up: rather than resolving the name on the client (extra fetch + an ApplicationName-style component), it may be cleaner to extend the response body of the account grants endpoint (GET /api/my-account/sessions) to include the application name alongside clientId — the backend already has the application record at hand when assembling grants, so it can join the name server-side. That keeps GrantRow a dumb renderer and avoids an N+1 lookup per app on the client.
There was a problem hiding this comment.
Agreed — extending the backend response is the cleaner approach. Will address as a follow-up since it requires a schema/API change.
| useEffect(() => { | ||
| if (!verificationId) { | ||
| return; | ||
| } | ||
|
|
||
| const pendingAction = sessionStorage.getPendingVerifiedAction(); | ||
|
|
||
| if (pendingAction !== 'load-sessions') { | ||
| return; | ||
| } | ||
|
|
||
| sessionStorage.clearPendingVerifiedAction(); | ||
| void fetchData(verificationId); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [verificationId]); | ||
|
|
||
| useEffect(() => { | ||
| if (hasLoaded || isLoading || !verificationId) { | ||
| return; | ||
| } | ||
|
|
||
| const pendingAction = sessionStorage.getPendingVerifiedAction(); | ||
| if (pendingAction === 'load-sessions') { | ||
| return; | ||
| } | ||
|
|
||
| void fetchData(verificationId); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [verificationId, hasLoaded, isLoading]); |
There was a problem hiding this comment.
Double-fetch race between these two effects on the verified-return path. When returning with pendingAction === 'load-sessions', both effects run on the same commit: effect 1 calls clearPendingVerifiedAction() then fetchData(), but its setIsLoading(true) isn't visible to effect 2's stale isLoading=false closure. Because effect 1 already cleared the sessionStorage flag, effect 2's pendingAction === 'load-sessions' guard no longer bails and it calls fetchData() a second time → two concurrent GET /sessions + GET /grants pairs (and a doubled error handler on permission_denied). Consider collapsing to a single load effect keyed off (verificationId, pendingAction).
There was a problem hiding this comment.
Fixed — collapsed to a single useEffect keyed on verificationId. Added an isFetchingRef guard to prevent concurrent fetches. The effect clears pendingAction if present and calls fetchData in one path.
| const [sessionError, sessionResult] = await getSessionsApi(verifiedId); | ||
|
|
||
| if (sessionError) { | ||
| await handleError(sessionError, { | ||
| 'verification_record.permission_denied': async () => { | ||
| setVerificationId(undefined); | ||
| setToast(t('account_center.verification.verification_required')); | ||
| }, | ||
| }); | ||
| setIsLoading(false); | ||
| return; | ||
| } | ||
|
|
||
| if (sessionResult) { | ||
| setSessions(sessionResult.sessions); | ||
| } | ||
|
|
||
| const [grantError, grantResult] = await getGrantsApi(verifiedId); |
There was a problem hiding this comment.
Sessions and grants are fetched strictly sequentially, but the two requests are independent. Running them with Promise.all (then applying error handling per-result) would roughly halve time-to-content.
There was a problem hiding this comment.
Fixed — sessions and grants now fetch in parallel with Promise.all.
| </div> | ||
| </div> | ||
|
|
||
| {hasLoaded && grantRows !== undefined && ( |
There was a problem hiding this comment.
When getGrants fails with a non-permission_denied error (500/timeout), grantRows stays undefined, so this whole section is hidden via grantRows !== undefined. A toast fires, but the section's absence is indistinguishable from "no apps" — the user can't tell grant loading failed. Consider an explicit error/empty state instead of hiding.
There was a problem hiding this comment.
Fixed — the grants section now always renders when hasLoaded is true. If grantRows is undefined (fetch failed), it falls through to the "no third-party apps" empty state. The toast from handleError already fires to indicate the failure.
| const getParsedUserAgentInfo = (userAgent?: string): ParsedUserAgentInfo => { | ||
| if (!userAgent) { | ||
| return {}; | ||
| } | ||
|
|
||
| const { device, browser, os } = new UAParser(userAgent).getResult(); | ||
| const deviceModel = [device.vendor, device.model].filter(Boolean).join(' ') || undefined; | ||
|
|
||
| return { | ||
| browserName: browser.name, | ||
| osName: os.name, | ||
| deviceModel, | ||
| }; | ||
| }; |
There was a problem hiding this comment.
getParsedUserAgentInfo, formatSessionDeviceName, formatSessionLocation, getSessionDisplayInfo (and formatTimestamp below) are near byte-for-byte copies of the Console's packages/console/src/pages/UserDetails/UserSettings/UserSessions/utils.ts + UserSessionDetails. normalizeGrantRows likewise duplicates Console's use-user-third-party-grants.ts. These are pure functions whose only non-trivial dep (userSessionSignInContextGuard) is in @logto/schemas, and both apps already import @logto/shared/universal — so this could move there and be shared. The copies have already drifted (Console surfaces city/country; this one drops them), so fixes will otherwise land in only one place.
There was a problem hiding this comment.
Agreed — these are near-identical pure functions. Moving them to @logto/shared/universal (which both apps already import) would eliminate the drift risk. Filed as a follow-up since it touches Console code too.
|
|
||
| const timestamp = value < 1_000_000_000_000 ? value * 1000 : value; | ||
|
|
||
| return new Date(timestamp).toLocaleString(); |
There was a problem hiding this comment.
toLocaleString() is called with no locale, so timestamps won't follow the user's selected UI language (the Console grant formatter passes i18n.language, and account-center already has utils/date.ts getDateFnsLocale for this). Given the PR ships 18 locales, dates will render in the runtime default rather than the chosen language. Same applies to line 132.
There was a problem hiding this comment.
Fixed — formatTimestamp now accepts a language parameter and uses date-fns format(date, 'PPp', { locale: getDateFnsLocale(language) }), consistent with PasskeyView. Both SessionRow and GrantRow pass i18n.language. Also changed GrantedAppRow.createdAt to iat (numeric) so formatting happens in the component with the correct locale.
| const SessionRow = ({ session, isEditable, isCurrent, onRevoke }: SessionRowProps) => { | ||
| const { t } = useTranslation(); | ||
|
|
||
| const { name, location, ip } = getSessionDisplayInfo(session); |
There was a problem hiding this comment.
When getSessionDisplayInfo returns {} — which happens whenever lastSubmission is null (a schema-legal state, the guard is .nullable()) or signInContext is missing — deviceName collapses to - and the meta line is empty. A user with several older sessions then sees multiple identical - rows and can't tell which device they're revoking. Consider a more identifying fallback (e.g. IP, or signed-in date emphasized).
There was a problem hiding this comment.
Fixed — when name is undefined (no signInContext or UA parse fails), the fallback chain is now name ?? ip ?? signedInAt, so the row always shows something identifying.
| } | ||
|
|
||
| return Array.from(groupedByApplicationId.entries()) | ||
| .slice() |
There was a problem hiding this comment.
nit: .slice() here is a no-op — Array.from(...) already returns a fresh array, so there's nothing to copy/protect before .sort(). (Carried over from the Console copy.) Can be removed.
There was a problem hiding this comment.
Kept .slice() — it's semantically a no-op after Array.from(), but the @silverhand/fp/no-mutating-methods ESLint rule requires it (it can't statically prove the array is fresh).
| const hideLogtoBranding = experienceSettings?.hideLogtoBranding === true; | ||
| const { pathname } = useLocation(); | ||
| const showsSecurityPage = hasVisibleSecuritySection(accountCenterSettings, experienceSettings); | ||
| const showsSessionsPage = isDevFeaturesEnabled && hasVisibleSessionsPage(accountCenterSettings); |
There was a problem hiding this comment.
nit: showsSessionsPage = isDevFeaturesEnabled && hasVisibleSessionsPage(...) is computed identically here and in Main (line 134), and the isDevFeaturesEnabled dev-gate is now scattered across routes + nav. Folding the dev-gate into hasVisibleSessionsPage (or one derived value) avoids the "nav shows but route 404s" failure mode when sessions graduate out of dev features.
There was a problem hiding this comment.
Fixed — folded isDevFeaturesEnabled into hasVisibleSessionsPage in security-page.ts. Both call sites in App.tsx (Main + Layout) now just call hasVisibleSessionsPage(accountCenterSettings) without the separate dev-gate.
| case 'remove-social': { | ||
| return accountCenterSettings.fields.social === AccountCenterControlValue.Edit; | ||
| } | ||
| case 'load-sessions': { |
There was a problem hiding this comment.
nit: this load-sessions gate checks only fields.session !== Off, with no isDevFeaturesEnabled guard — unlike the Sessions route/nav in App.tsx. Latent only today (the sole writer of the flag is the dev-gated Sessions page), but a stale sessionStorage flag from a prior dev-enabled session would be treated as allowed while the route doesn't exist.
There was a problem hiding this comment.
Fixed — added isDevFeaturesEnabled guard to the load-sessions case.
…, locale-aware dates - Parallel fetch sessions and grants with Promise.all - Collapse two useEffects into a single load effect with ref guard to prevent double-fetch race - Use date-fns format with locale for timestamps instead of bare toLocaleString() - Always show grants section after load (grantRows undefined falls through to empty state) - Better SessionRow fallback: show IP or sign-in date when device info unavailable - Fold isDevFeaturesEnabled into hasVisibleSessionsPage to centralize the dev gate - Add isDevFeaturesEnabled guard to load-sessions case in VerifiedAction - Change GrantedAppRow.createdAt to iat (numeric) for locale-aware formatting in component
| {hasLoaded && ( | ||
| <div className={classNames(styles.section, layoutClassNames.section)}> | ||
| <div className={classNames(styles.sectionTitle, layoutClassNames.sectionTitle)}> | ||
| {t('account_center.sessions.third_party_apps_title')} | ||
| </div> | ||
| <div className={classNames(styles.card, layoutClassNames.card)}> | ||
| {grantRows && grantRows.length > 0 ? ( |
| export const hasVisibleSessionsPage = (accountCenterSettings?: SecurityPageSettings): boolean => { | ||
| if (!isDevFeaturesEnabled || !accountCenterSettings?.enabled) { | ||
| return false; | ||
| } | ||
|
|
||
| return isVisibleField(accountCenterSettings.fields.session); |
Summary
Add standalone Sessions page (
/sessions) to account center with two sections: Sessions and Third-party grants, aligned with Console's User Management UI patterns./sessions(parallel to/security,/profile), gated byaccountCenter.fields.session(Off/ReadOnly/Edit)ua-parser-js+safeParse()), location, IP, sign-in timestamp, "Current session" badge. Edit mode shows "Sign out" for non-current sessionsapplicationId, shows authorized date, "Remove" button in Edit mode. Only renders when grants fetch succeedsVerifiedActionwith'load-sessions'pending actionhasVisibleSessionsPage()extracted so session field no longer gates Security page visibilityTesting
Tested locally
Link to Devin session: https://app.devin.ai/sessions/d135c0c480f6408da2d84b708b66b472
Requested by: @wangsijie