Add multi-account auth support with quick account switcher UI#344
Add multi-account auth support with quick account switcher UI#344priyanshrv1-oss wants to merge 19 commits intoOpenCloudGaming:mainfrom
Conversation
Add multi-account auth support with quick account switcher UI
There was a problem hiding this comment.
Pull request overview
Adds multi-account authentication with a navbar dropdown to view, switch, add, and remove saved accounts without re-entering credentials.
Changes:
- Extended shared IPC/API contracts to support listing saved accounts and switching/removing accounts.
- Implemented multi-session persistence in
AuthService(sessions map +activeUserId) and wired new IPC handlers. - Added renderer quick-switcher dropdown UI and integrated saved-account state/handlers in
App.tsx.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| opennow-stable/src/shared/ipc.ts | Adds new IPC channel constants for saved-accounts, switch, and remove. |
| opennow-stable/src/shared/gfn.ts | Adds SavedAccount type and extends OpenNowApi with account operations. |
| opennow-stable/src/preload/index.ts | Exposes new auth account APIs via ipcRenderer.invoke in the preload bridge. |
| opennow-stable/src/main/index.ts | Registers IPC handlers for saved accounts, switching, removing, and updates logout behavior. |
| opennow-stable/src/main/gfn/auth.ts | Migrates to multi-session storage, persistence, and implements account operations. |
| opennow-stable/src/renderer/src/components/Navbar.tsx | Adds the account dropdown UI and wiring for switching/removing/adding accounts. |
| opennow-stable/src/renderer/src/App.tsx | Loads saved accounts at startup and adds handlers to switch/remove/add accounts. |
| opennow-stable/src/renderer/src/styles.css | Adds styling for the account dropdown and related controls. |
Comments suppressed due to low confidence (1)
opennow-stable/src/main/gfn/auth.ts:1073
- In
ensureValidSessionWithStatus, when a session is expired you callawait this.logout()and then returnsession: this.getSession(). Sincelogout()advancesactiveUserIdto the next saved session, this can cause callers (e.g.AUTH_GET_SESSIONrestore, orswitchAccount) to receive a different account’s session while the refresh status message still refers to the expired session. Consider returningsession: nullfor the expired-session cases (and/or preserving the pre-logout session in the return value) to avoid silent cross-account switches.
if (!tokens.clientToken && !tokens.refreshToken) {
if (expired) {
await this.logout();
return {
session: this.getSession(),
refresh: {
attempted: true,
forced: forceRefresh,
outcome: "missing_refresh_token",
message: "Saved session expired and has no refresh mechanism. Please log in again.",
},
};
| const handleSwitchAccount = useCallback(async (userId: string) => { | ||
| try { | ||
| const session = await window.openNow.switchAccount(userId); | ||
| setAuthSession(session); | ||
| setProviderIdpId(session.provider.idpId); | ||
| await refreshSavedAccounts(); | ||
| await loadSessionRuntimeData(session); | ||
| await refreshNavbarActiveSession(); | ||
| } catch (error) { |
There was a problem hiding this comment.
handleSwitchAccount calls refreshNavbarActiveSession() immediately after setAuthSession(session), but refreshNavbarActiveSession closes over the previous authSession value. This can cause the active-session lookup to run with the wrong token/base URL right after switching accounts. Consider updating refreshNavbarActiveSession to accept a session/token argument (or trigger it via an effect that runs when authSession changes) instead of calling it directly here.
| setLoginError(error instanceof Error ? error.message : "Failed to switch account"); | ||
| await refreshSavedAccounts(); | ||
| const sessionResult = await window.openNow.getAuthSession(); | ||
| setAuthSession(sessionResult.session); |
There was a problem hiding this comment.
In the catch path for handleSwitchAccount, the recovered session from getAuthSession() is stored via setAuthSession(...), but related state (providerIdpId, regions/games/subscription via loadSessionRuntimeData) is not updated. If the main process switched the active session (e.g. because the target account was removed/invalid), the renderer can end up showing data for the wrong provider/account until a full refresh. Update providerIdpId and re-run the runtime-data load (or explicitly restore the prior session) when sessionResult.session is non-null.
| setAuthSession(sessionResult.session); | |
| setAuthSession(sessionResult.session); | |
| if (sessionResult.session) { | |
| setProviderIdpId(sessionResult.session.provider.idpId); | |
| await loadSessionRuntimeData(sessionResult.session); | |
| await refreshNavbarActiveSession(); | |
| return; | |
| } | |
| setRegions([]); | |
| setGames([]); | |
| setLibraryGames([]); | |
| setSubscriptionInfo(null); | |
| setCatalogFilterGroups([]); | |
| setCatalogSortOptions([]); | |
| setCatalogTotalCount(0); | |
| setCatalogSupportedCount(0); | |
| await refreshNavbarActiveSession(); |
| if (!result.session || result.refresh.outcome === "failed") { | ||
| await this.removeAccount(userId); | ||
| throw new Error(result.refresh.message); | ||
| } | ||
| return result.session; |
There was a problem hiding this comment.
switchAccount treats only refresh.outcome === "failed" as an error. When ensureValidSessionWithStatus(true) returns outcome: "missing_refresh_token" for an expired session, it can call logout() internally and result.session may become a different account (or null), yet this code will still return result.session as if the switch succeeded. Make the success criteria stricter (e.g. fail on missing_refresh_token when expired, and/or verify result.session?.user.userId === userId before returning).
| if (!result.session || result.refresh.outcome === "failed") { | |
| await this.removeAccount(userId); | |
| throw new Error(result.refresh.message); | |
| } | |
| return result.session; | |
| const switchedSession = result.session; | |
| const refreshFailed = | |
| result.refresh.outcome === "failed" || | |
| result.refresh.outcome === "missing_refresh_token"; | |
| const switchedToRequestedUser = switchedSession?.user.userId === userId; | |
| if (!switchedSession || refreshFailed || !switchedToRequestedUser) { | |
| await this.removeAccount(userId); | |
| throw new Error( | |
| !switchedSession | |
| ? result.refresh.message | |
| : !switchedToRequestedUser | |
| ? "Switched session did not match the requested account" | |
| : result.refresh.message, | |
| ); | |
| } | |
| return switchedSession; |
| @@ -2388,17 +2353,84 @@ export function App(): JSX.Element { | |||
| applyCatalogBrowseResult(catalogResult); | |||
| setLibraryGames(libGames); | |||
| applyVariantSelections(libGames); | |||
| } catch (catalogError) { | |||
| console.error("Initialization games load failed:", catalogError); | |||
| setGames([]); | |||
| setLibraryGames([]); | |||
| setCatalogFilterGroups([]); | |||
| setCatalogSortOptions([]); | |||
| setCatalogTotalCount(0); | |||
| setCatalogSupportedCount(0); | |||
| } | |||
| } | |||
There was a problem hiding this comment.
loadSessionRuntimeData is declared as an inline function but is referenced in multiple hook dependency arrays (e.g. useEffect initialize deps and useCallback handlers). Because its identity changes each render, those hooks will re-evaluate unnecessarily and useCallback memoization is effectively defeated. Convert loadSessionRuntimeData to a useCallback (with the required dependencies like catalogSelectedSortId, catalogSelectedFilterIds, applyCatalogBrowseResult, applyVariantSelections, loadSubscriptionInfo) and then depend on that stable callback.
…nfirmation Add confirmation modal for account removal
Kief5555
left a comment
There was a problem hiding this comment.
Over all, nice looking UI.
Need to consider account status change, such as expired tokens, revocations, etc.
| setAccountDropdownOpen(false); | ||
| } | ||
| }; | ||
| window.addEventListener("mousedown", onDocumentPointerDown); |
There was a problem hiding this comment.
Why are we using useEffect when we can trigger a state change from passing an onCilck event from the component?
| } | ||
| }; | ||
| window.addEventListener("mousedown", onDocumentPointerDown); | ||
| return () => window.removeEventListener("mousedown", onDocumentPointerDown); |
| this.clearVpcCache(); | ||
|
|
||
| const result = await this.ensureValidSessionWithStatus(true); | ||
| if (!result.session || result.refresh.outcome === "failed") { |
- Remove global mousedown listener (useEffect pattern) - Add clickable backdrop button with z-index layering - Addresses reviewer feedback from PR OpenCloudGaming#344 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
|
@copilot apply changes based on the comments in this thread |
* Fix account switch session validation during refresh * Fail switch-account refresh when user identity cannot be verified * Update opennow-stable/src/main/gfn/auth.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: PriyanshAg-1 <204138848+PriyanshAg-1@users.noreply.github.com> Co-authored-by: priyanshrv1-oss <priyanshrv1@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
Re-request review when its ready for a merge. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (2)
opennow-stable/src/main/gfn/auth.ts:1118
- In the expired-session path you call
await this.logout()(which can advanceactiveUserIdto a different saved account) and then returnsession: this.getSession(). That meansensureValidSessionWithStatusmay return a different user's session while the refresh outcome/message refers to the expired session. Capture the pre-logout session/userId and returnsession: nullfor this failure (or return a separate field indicating fallback account) so callers don’t silently switch accounts.
if (!tokens.clientToken && !tokens.refreshToken) {
if (expired) {
await this.logout();
return {
session: this.getSession(),
refresh: {
attempted: true,
forced: forceRefresh,
outcome: "missing_refresh_token",
message: "Saved session expired and has no refresh mechanism. Please log in again.",
},
opennow-stable/src/main/gfn/auth.ts:1144
- Same issue as above: after
await this.logout()in the expired-token refresh-failed branch, returningsession: this.getSession()can yield the next saved account instead ofnull, contradicting thefailedoutcome. Returnnull(and keep other accounts intact) rather than implicitly switching the active account in this method’s response.
if (expired) {
await this.logout();
return {
session: this.getSession(),
refresh: {
attempted: true,
forced: forceRefresh,
outcome: "failed",
message: "Token refresh failed and the saved session expired. Please log in again.",
error: errorText,
},
};
| const session = await window.openNow.switchAccount(userId); | ||
| setAuthSession(session); | ||
| setProviderIdpId(session.provider.idpId); | ||
| await refreshSavedAccounts(); | ||
| await loadSessionRuntimeData(session); | ||
| await refreshNavbarActiveSession(); | ||
| } catch (error) { |
There was a problem hiding this comment.
refreshNavbarActiveSession() reads from the authSession state, but here you call it immediately after setAuthSession(session). Because state updates are async, this refresh can run with the previous account token. Prefer passing the freshly returned session into refreshNavbarActiveSession (or keeping an authSession ref) so the active-session query uses the correct account after switching.
| setLoginError(error instanceof Error ? error.message : "Failed to switch account"); | ||
| await refreshSavedAccounts(); | ||
| const sessionResult = await window.openNow.getAuthSession(); | ||
| setAuthSession(sessionResult.session); | ||
| } |
There was a problem hiding this comment.
In the switch-account error recovery path you restore authSession from getAuthSession(), but you don’t sync providerIdpId (and you don’t reload regions/subscription/games for the restored session). If recovery returns a non-null session, update providerIdpId and refresh runtime data to keep UI/state consistent; otherwise clear session-scoped state like you do elsewhere.
| aria-expanded={accountDropdownOpen} | ||
| aria-haspopup="menu" | ||
| > | ||
| {user.avatarUrl ? ( | ||
| <img src={user.avatarUrl} alt={user.displayName} className="navbar-avatar" /> | ||
| ) : ( | ||
| <div className="navbar-avatar-fallback"> | ||
| <User size={14} /> | ||
| </div> | ||
| )} | ||
| <div className="navbar-user-info"> | ||
| <span className="navbar-username">{user.displayName}</span> | ||
| {tierInfo && ( | ||
| <span className={`navbar-tier ${tierInfo.className}`}>{tierInfo.label}</span> | ||
| )} | ||
| </div> | ||
| <ChevronDown | ||
| size={14} | ||
| className={`navbar-user-chevron${accountDropdownOpen ? " is-open" : ""}`} | ||
| /> | ||
| </button> | ||
| {accountDropdownOpen && ( | ||
| <div className="navbar-account-dropdown" role="menu" aria-label="Switch account"> | ||
| <div className="navbar-account-dropdown-header">Switch Account</div> | ||
| <div className="navbar-account-list"> | ||
| {savedAccounts.map((account) => { | ||
| const accountTierInfo = getTierDisplay(account.membershipTier); | ||
| const isActive = activeUserId === account.userId; | ||
| const canRemove = !isActive && savedAccounts.length > 1; | ||
| return ( | ||
| <div | ||
| key={account.userId} | ||
| className={`navbar-account-item${isActive ? " navbar-account-item--active" : ""}`} | ||
| > | ||
| <button | ||
| type="button" | ||
| className="navbar-account-item-main" | ||
| onClick={() => { | ||
| if (!isActive) { | ||
| onSwitchAccount(account.userId); | ||
| } | ||
| setAccountDropdownOpen(false); | ||
| }} | ||
| disabled={isActive} | ||
| > |
There was a problem hiding this comment.
The dropdown uses role="menu" / aria-haspopup="menu", but the children aren’t exposed as menuitems and there’s no keyboard navigation (arrow keys, Home/End) expected for ARIA menus. Either implement proper menu semantics (menuitem roles + keyboard handling) or use a less strict pattern (e.g., a popover/list with appropriate ARIA) to avoid confusing screen readers.
| getSavedAccounts(): Array<{ | ||
| userId: string; | ||
| displayName: string; | ||
| email?: string; | ||
| avatarUrl?: string; | ||
| membershipTier: string; | ||
| providerCode: string; | ||
| }> { |
There was a problem hiding this comment.
getSavedAccounts() duplicates the SavedAccount shape inline instead of reusing the shared SavedAccount interface you added in @shared/gfn. Reusing the shared type helps prevent renderer/main drift as the account card fields evolve.
|
@copilot apply changes based on the comments in this thread |
…o refreshNavbarActiveSession to avoid stale auth token Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Fix async session state in handleSwitchAccount
…rror recovery state sync, ARIA semantics, SavedAccount type usage Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…after logout Agent-Logs-Url: https://github.com/priyanshrv1-oss/OpenNOW1/sessions/bc9cb33e-8e97-4cf8-8986-2a9a584abddc Co-authored-by: PriyanshAg-1 <204138848+PriyanshAg-1@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…ne numbers in TS files Agent-Logs-Url: https://github.com/priyanshrv1-oss/OpenNOW1/sessions/f707cd73-f431-41cf-96a8-c5e0c34ab939 Co-authored-by: PriyanshAg-1 <204138848+PriyanshAg-1@users.noreply.github.com>
| type="button" | ||
| className="navbar-account-remove" | ||
| aria-label={`Remove ${account.displayName}`} | ||
| onClick={() => { |
There was a problem hiding this comment.
Clicking the remove (X) button triggers onRemoveAccount(...) but does not close the account dropdown. Since removal opens a confirmation modal, leaving the dropdown open behind it can lead to overlapping UI/focus issues. Consider closing the dropdown (setAccountDropdownOpen(false)) when initiating account removal.
| onClick={() => { | |
| onClick={() => { | |
| setAccountDropdownOpen(false); |
|
|
||
| void initialize(); | ||
| }, []); | ||
| }, [catalogFilterKey, catalogSelectedSortId, loadSessionRuntimeData]); |
There was a problem hiding this comment.
The init useEffect dependency list includes loadSessionRuntimeData, which is a function declared inside the component (not memoized). This makes the effect re-run on every render and can repeatedly call initialize()/set state unexpectedly. Convert loadSessionRuntimeData to useCallback (with the right deps) or remove it from the dependency list (and keep a stable helper) so initialization runs exactly once.
| }, [catalogFilterKey, catalogSelectedSortId, loadSessionRuntimeData]); | |
| }, []); |
| const confirmLogout = useCallback(async () => { | ||
| setLogoutConfirmOpen(false); | ||
| await window.openNow.logout(); | ||
| setAuthSession(null); | ||
| setSavedAccounts([]); | ||
| setGames([]); |
There was a problem hiding this comment.
window.openNow.logout() now signs out all saved accounts (main IPC handler calls authService.logoutAll()), and this handler clears savedAccounts accordingly. The UI copy/labels should consistently communicate this (e.g., the existing logout confirmation modal text still reads like a single-session/device logout). Either update the modal wording to “sign out all accounts” or split into separate APIs/actions for logout-current vs logout-all.
| if (!tokens.clientToken && !tokens.refreshToken) { | ||
| if (expired) { | ||
| await this.logout(); | ||
| return { | ||
| session: null, | ||
| session: this.getSession(), | ||
| refresh: { | ||
| attempted: true, | ||
| forced: forceRefresh, | ||
| outcome: "missing_refresh_token", | ||
| message: "Saved session expired and has no refresh mechanism. Please log in again.", | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| session: this.session, | ||
| session: this.getSession(), | ||
| refresh: { | ||
| attempted: true, | ||
| forced: forceRefresh, | ||
| outcome: "missing_refresh_token", | ||
| message: "No refresh token available. Using saved session token.", | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| if (expired) { | ||
| await this.logout(); | ||
| return { | ||
| session: null, | ||
| session: this.getSession(), | ||
| refresh: { |
There was a problem hiding this comment.
If the active session expires and ensureValidSessionWithStatus calls logout(), the returned session is this.getSession(). Because logout() may advance activeUserId to another saved session, this can cause ensureValidSessionWithStatus to return a different user's session than the one being validated, without any explicit signal to the caller. Consider returning null (or the pre-logout session) for these expiry branches, or explicitly indicating that the active account was removed/switched so the renderer can handle it deterministically.
| ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async () => { | ||
| await authService.logout(); | ||
| await authService.logoutAll(); | ||
| }); |
There was a problem hiding this comment.
AUTH_LOGOUT now calls authService.logoutAll(), which changes the meaning of the existing auth:logout channel to "sign out all accounts". This is a breaking semantic change for an existing API name/channel; consider adding a dedicated auth:logout-all channel (and logoutAll() API) and keeping auth:logout as "logout active account" (or renaming the channel/API for clarity).
| {accountDropdownOpen && ( | ||
| <div className="navbar-account-dropdown" role="menu" aria-label="Switch account"> | ||
| <div className="navbar-account-dropdown-header">Switch Account</div> |
There was a problem hiding this comment.
The dropdown uses role="menu" but the children are plain <button>s without role="menuitem" / roving tabindex / arrow-key handling. This ARIA pattern can confuse screen readers. Either implement the full ARIA menu behavior (menuitem roles + keyboard navigation) or remove the role="menu" and treat this as a regular popover/list with simpler semantics.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Fix all Copilot review issues
Kief5555
left a comment
There was a problem hiding this comment.
Please conduct testing and provide a video when the errors have been fixed.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated 9 comments.
Comments suppressed due to low confidence (2)
opennow-stable/src/main/gfn/auth.ts:1138
- Same issue as above: when refresh fails and the token is expired, this branch calls
await this.logout()and then returnssession: this.getSession(), which may now refer to a different saved account. Returning a different account's session here can cause unintended account switching. Suggest returningsession: null(or the original session) for the failure result and handling selection of a fallback account separately.
if (expired) {
await this.logout();
return {
session: this.getSession(),
refresh: {
attempted: true,
forced: forceRefresh,
outcome: "failed",
message: "Token refresh failed and the saved session expired. Please log in again.",
error: errorText,
},
};
opennow-stable/src/main/gfn/auth.ts:1113
- In the branches where the current session is expired,
ensureValidSessionWithStatuscallsawait this.logout()and then returnssession: this.getSession(). With multi-account,logout()can advanceactiveUserIdto a different saved account, so this method can unexpectedly return a different user's session alongside an error about the expired one. Consider returningsession: null(or the pre-logout session) in these branches and avoid implicitly switching accounts during expiry handling (e.g., remove only the expired account without advancing, or advance but still return null + a separate next-session signal).
if (!tokens.clientToken && !tokens.refreshToken) {
if (expired) {
await this.logout();
return {
session: this.getSession(),
refresh: {
attempted: true,
forced: forceRefresh,
outcome: "missing_refresh_token",
message: "Saved session expired and has no refresh mechanism. Please log in again.",
},
};
| AUTH_GET_REGIONS: "auth:get-regions", | ||
| AUTH_LOGIN: "auth:login", | ||
| AUTH_LOGOUT: "auth:logout", | ||
| AUTH_GET_SAVED_ACCOUNTS: "auth:get-saved-accounts", | ||
| AUTH_SWITCH_ACCOUNT: "auth:switch-account", | ||
| AUTH_REMOVE_ACCOUNT: "auth:remove-account", |
There was a problem hiding this comment.
The PR description/UI mention “Sign out all accounts”, and AuthService has logoutAll(), but there’s no shared IPC channel / OpenNowApi surface for it. As-is, the app can only call auth:logout (single account), so “sign out all” can’t be implemented end-to-end. Add a dedicated IPC channel + OpenNowApi method (e.g., AUTH_LOGOUT_ALL) and wire it through main/preload/renderer to call authService.logoutAll().
| const api: OpenNowApi = { | ||
| getAuthSession: (input: AuthSessionRequest = {}) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_SESSION, input), | ||
| getLoginProviders: () => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_PROVIDERS), | ||
| getRegions: (input: RegionsFetchRequest = {}) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_REGIONS, input), | ||
| login: (input: AuthLoginRequest) => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGIN, input), | ||
| logout: () => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGOUT), | ||
| getSavedAccounts: (): Promise<SavedAccount[]> => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_SAVED_ACCOUNTS), | ||
| switchAccount: (userId: string): Promise<AuthSession> => | ||
| ipcRenderer.invoke(IPC_CHANNELS.AUTH_SWITCH_ACCOUNT, userId), | ||
| removeAccount: (userId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.AUTH_REMOVE_ACCOUNT, userId), |
There was a problem hiding this comment.
Preload exposes logout() plus the new account methods, but there’s no preload method for “logout all” even though main has AuthService.logoutAll() and the UI indicates a sign-out-all action. Add a logoutAll method that invokes a dedicated IPC channel so renderer can reliably clear all persisted sessions.
| <button | ||
| type="button" | ||
| className="navbar-account-signout-all" | ||
| onClick={() => { | ||
| onLogout(); | ||
| setAccountDropdownOpen(false); | ||
| }} | ||
| > | ||
| Sign out all accounts | ||
| </button> |
There was a problem hiding this comment.
This button is labeled “Sign out all accounts” but it calls the generic onLogout() handler. Combined with the lack of a logoutAll IPC/API surface, this will at best sign out only the active account while the UI implies a full multi-account wipe. Suggest wiring this to a dedicated onLogoutAll handler that calls a window.openNow.logoutAll() API.
| if (expectedUserId && !refreshedUser) { | ||
| return { | ||
| session: latestSession, | ||
| refresh: { | ||
| attempted: true, | ||
| forced: forceRefresh, | ||
| outcome: "failed", | ||
| message: "Token refresh could not verify the expected account identity.", | ||
| error: userInfoError ?? `expected_user_id:${expectedUserId} user_info_unavailable`, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| if (expectedUserId && refreshedUser && refreshedUser.userId !== expectedUserId) { | ||
| return { | ||
| session: latestSession, | ||
| refresh: { | ||
| attempted: true, | ||
| forced: forceRefresh, | ||
| outcome: "failed", | ||
| message: "Token refresh returned a different account than expected.", | ||
| error: `expected_user_id:${expectedUserId} actual_user_id:${refreshedUser.userId}`, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| const resolvedUser = refreshedUser ?? latestSession.user; |
There was a problem hiding this comment.
When expectedUserId is provided, a failure to fetch user info during refresh forces outcome: "failed" even if the token refresh itself succeeded. This makes account switching brittle (e.g., transient MES/user-info outage) and can cascade into saved-account removal in switchAccount. Consider allowing refresh to succeed using the cached user identity when user-info fetch fails, and only fail/remove if the refreshed userId is known to mismatch.
| if (expectedUserId && !refreshedUser) { | |
| return { | |
| session: latestSession, | |
| refresh: { | |
| attempted: true, | |
| forced: forceRefresh, | |
| outcome: "failed", | |
| message: "Token refresh could not verify the expected account identity.", | |
| error: userInfoError ?? `expected_user_id:${expectedUserId} user_info_unavailable`, | |
| }, | |
| }; | |
| } | |
| if (expectedUserId && refreshedUser && refreshedUser.userId !== expectedUserId) { | |
| return { | |
| session: latestSession, | |
| refresh: { | |
| attempted: true, | |
| forced: forceRefresh, | |
| outcome: "failed", | |
| message: "Token refresh returned a different account than expected.", | |
| error: `expected_user_id:${expectedUserId} actual_user_id:${refreshedUser.userId}`, | |
| }, | |
| }; | |
| } | |
| const resolvedUser = refreshedUser ?? latestSession.user; | |
| const resolvedUser = refreshedUser ?? latestSession.user; | |
| if (expectedUserId && resolvedUser.userId !== expectedUserId) { | |
| return { | |
| session: latestSession, | |
| refresh: { | |
| attempted: true, | |
| forced: forceRefresh, | |
| outcome: "failed", | |
| message: refreshedUser | |
| ? "Token refresh returned a different account than expected." | |
| : "Token refresh kept a cached account identity that did not match the expected account.", | |
| error: refreshedUser | |
| ? `expected_user_id:${expectedUserId} actual_user_id:${refreshedUser.userId}` | |
| : userInfoError | |
| ? `expected_user_id:${expectedUserId} cached_user_id:${resolvedUser.userId} user_info_error:${userInfoError}` | |
| : `expected_user_id:${expectedUserId} cached_user_id:${resolvedUser.userId}`, | |
| }, | |
| }; | |
| } |
| getRegions(input?: RegionsFetchRequest): Promise<StreamRegion[]>; | ||
| login(input: AuthLoginRequest): Promise<AuthSession>; | ||
| logout(): Promise<void>; | ||
| getSavedAccounts(): Promise<SavedAccount[]>; | ||
| switchAccount(userId: string): Promise<AuthSession>; | ||
| removeAccount(userId: string): Promise<void>; |
There was a problem hiding this comment.
The shared OpenNowApi now exposes account switching/removal, but there’s still only logout() even though the PR describes “sign out all accounts” and main AuthService includes logoutAll(). Add logoutAll(): Promise<void> (or similarly named) to the shared contract so renderer can call it without overloading logout() semantics.
| ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async () => { | ||
| await authService.logout(); | ||
| }); | ||
|
|
||
| ipcMain.handle(IPC_CHANNELS.AUTH_GET_SAVED_ACCOUNTS, async () => { | ||
| return authService.getSavedAccounts(); | ||
| }); | ||
|
|
||
| ipcMain.handle(IPC_CHANNELS.AUTH_SWITCH_ACCOUNT, async (_event, userId: string) => { | ||
| return authService.switchAccount(userId); | ||
| }); | ||
|
|
||
| ipcMain.handle(IPC_CHANNELS.AUTH_REMOVE_ACCOUNT, async (_event, userId: string) => { | ||
| await authService.removeAccount(userId); | ||
| }); |
There was a problem hiding this comment.
Main registers handlers for saved-accounts/switch/remove, but there’s no IPC handler for an all-accounts logout even though AuthService.logoutAll() exists and the PR/UI describe that feature. Consider adding a new handler (e.g., AUTH_LOGOUT_ALL) that calls authService.logoutAll() so the renderer can implement “sign out all accounts” without relying on repeated single-account logout calls.
| useEffect(() => { | ||
| if (!accountDropdownOpen) return; | ||
| const onDocumentPointerDown = (event: MouseEvent) => { | ||
| if (!accountContainerRef.current?.contains(event.target as Node)) { | ||
| setAccountDropdownOpen(false); | ||
| } | ||
| }; | ||
| window.addEventListener("mousedown", onDocumentPointerDown); | ||
| return () => window.removeEventListener("mousedown", onDocumentPointerDown); |
There was a problem hiding this comment.
The outside-click handler casts event.target to Node unconditionally. At runtime event.target can be null (or not a Node), which would cause contains() to throw. Consider guarding with event.target instanceof Node (or checking for a truthy target) before calling contains.
| <button | ||
| type="button" | ||
| className="navbar-user navbar-user--clickable" | ||
| onClick={() => setAccountDropdownOpen((previous) => !previous)} | ||
| aria-haspopup="menu" | ||
| aria-expanded={accountDropdownOpen} | ||
| aria-controls="navbar-account-dropdown" | ||
| > | ||
| {user.avatarUrl ? ( | ||
| <img src={user.avatarUrl} alt={user.displayName} className="navbar-avatar" /> | ||
| ) : ( | ||
| <div className="navbar-avatar-fallback"> | ||
| <User size={14} /> | ||
| </div> | ||
| )} | ||
| <div className="navbar-user-info"> | ||
| <span className="navbar-username">{user.displayName}</span> | ||
| {tierInfo && ( | ||
| <span className={`navbar-tier ${tierInfo.className}`}>{tierInfo.label}</span> | ||
| )} | ||
| </div> | ||
| <ChevronDown | ||
| size={14} | ||
| className={`navbar-user-chevron${accountDropdownOpen ? " is-open" : ""}`} | ||
| /> | ||
| </button> | ||
| {accountDropdownOpen && ( | ||
| <div | ||
| id="navbar-account-dropdown" | ||
| className="navbar-account-dropdown" | ||
| role="region" | ||
| aria-labelledby="navbar-account-dropdown-header" | ||
| > | ||
| <div id="navbar-account-dropdown-header" className="navbar-account-dropdown-header"> | ||
| Switch Account | ||
| </div> | ||
| <ul className="navbar-account-list"> |
There was a problem hiding this comment.
The toggle button advertises aria-haspopup="menu", but the dropdown container uses role="region" and the list/buttons don’t use menu semantics (role="menu" / menuitem"). This mismatch can confuse assistive tech. Consider either: (1) implement proper menu roles/keyboard behavior, or (2) change aria-haspopup/roles to match a non-menu popover (e.g., aria-haspopup="dialog" and appropriate roles/labels).
There was a problem hiding this comment.
@copilot apply changes based on this feedback
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
🚀 Multi-Account Switcher (Quick Account Switching)
Overview
This PR introduces a multi-account system that allows users to manage and switch between multiple accounts seamlessly without re-entering credentials.
✨ Features
Multi-account support
Quick account switching
Account management UI
🛠️ Technical Changes
Backend (AuthService)
Migrated from single session → multiple sessions (
Map<string, AuthSession>)Added:
getSavedAccounts()switchAccount(userId)removeAccount(userId)logoutAll()Added session persistence with
activeUserIdImplemented backward compatibility for old session format
IPC & API
New IPC channels:
auth:get-saved-accountsauth:switch-accountauth:remove-accountExtended preload API for account operations
Frontend
App.tsxStyling
🔁 Behavior
✅ Testing
📌 Notes
This implementation is backward-compatible and migrates existing single-session users automatically.