Skip to content

Add multi-account auth support with quick account switcher UI#344

Open
priyanshrv1-oss wants to merge 19 commits intoOpenCloudGaming:mainfrom
priyanshrv1-oss:main
Open

Add multi-account auth support with quick account switcher UI#344
priyanshrv1-oss wants to merge 19 commits intoOpenCloudGaming:mainfrom
priyanshrv1-oss:main

Conversation

@priyanshrv1-oss
Copy link
Copy Markdown

🚀 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

    • Store multiple authenticated sessions
    • Track and switch active account
  • Quick account switching

    • Instantly switch accounts using stored refresh tokens
    • No password re-entry required
  • Account management UI

    • Dropdown menu in navbar
    • View all saved accounts with profile info
    • Active account indicator
    • Remove individual accounts
    • Add new accounts
    • Sign out of all accounts

🛠️ Technical Changes

Backend (AuthService)

  • Migrated from single session → multiple sessions (Map<string, AuthSession>)

  • Added:

    • getSavedAccounts()
    • switchAccount(userId)
    • removeAccount(userId)
    • logoutAll()
  • Added session persistence with activeUserId

  • Implemented backward compatibility for old session format

IPC & API

  • New IPC channels:

    • auth:get-saved-accounts
    • auth:switch-account
    • auth:remove-account
  • Extended preload API for account operations

Frontend

  • Navbar dropdown for account switching
  • Integrated state management in App.tsx
  • Handlers for switching, adding, and removing accounts

Styling

  • Added dropdown UI with dark theme support
  • Tier badges and interactive states

🔁 Behavior

  • New logins are added (not replaced)
  • Switching accounts validates/refreshes tokens
  • Invalid sessions are automatically removed
  • “Sign out all” clears all stored sessions

✅ Testing

  • Verified multi-account login flow
  • Tested switching without password prompts
  • Confirmed account removal and full logout
  • Build, typecheck, and tests all passing

📌 Notes

This implementation is backward-compatible and migrates existing single-session users automatically.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 call await this.logout() and then return session: this.getSession(). Since logout() advances activeUserId to the next saved session, this can cause callers (e.g. AUTH_GET_SESSION restore, or switchAccount) to receive a different account’s session while the refresh status message still refers to the expired session. Consider returning session: null for 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.",
          },
        };

Comment thread opennow-stable/src/renderer/src/App.tsx Outdated
Comment on lines +2384 to +2392
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) {
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread opennow-stable/src/renderer/src/App.tsx Outdated
setLoginError(error instanceof Error ? error.message : "Failed to switch account");
await refreshSavedAccounts();
const sessionResult = await window.openNow.getAuthSession();
setAuthSession(sessionResult.session);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
Comment thread opennow-stable/src/main/gfn/auth.ts Outdated
Comment on lines +644 to +648
if (!result.session || result.refresh.outcome === "failed") {
await this.removeAccount(userId);
throw new Error(result.refresh.message);
}
return result.session;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment thread opennow-stable/src/renderer/src/App.tsx Outdated
Comment on lines +2327 to +2365
@@ -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);
}
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
…nfirmation

Add confirmation modal for account removal
Copy link
Copy Markdown
Collaborator

@Kief5555 Kief5555 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Over all, nice looking UI.

Need to consider account status change, such as expired tokens, revocations, etc.

setAccountDropdownOpen(false);
}
};
window.addEventListener("mousedown", onDocumentPointerDown);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

Comment thread opennow-stable/src/main/gfn/auth.ts Outdated
this.clearVpcCache();

const result = await this.ensureValidSessionWithStatus(true);
if (!result.session || result.refresh.outcome === "failed") {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if token expires?

PriyanshAg-1 added a commit to priyanshrv1-oss/OpenNOW1 that referenced this pull request Apr 20, 2026
- 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>
@PriyanshAg-1
Copy link
Copy Markdown

@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>
@Kief5555
Copy link
Copy Markdown
Collaborator

Re-request review when its ready for a merge.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 advance activeUserId to a different saved account) and then return session: this.getSession(). That means ensureValidSessionWithStatus may return a different user's session while the refresh outcome/message refers to the expired session. Capture the pre-logout session/userId and return session: null for 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, returning session: this.getSession() can yield the next saved account instead of null, contradicting the failed outcome. Return null (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,
        },
      };

Comment thread opennow-stable/src/renderer/src/App.tsx Outdated
Comment on lines +2388 to +2394
const session = await window.openNow.switchAccount(userId);
setAuthSession(session);
setProviderIdpId(session.provider.idpId);
await refreshSavedAccounts();
await loadSessionRuntimeData(session);
await refreshNavbarActiveSession();
} catch (error) {
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread opennow-stable/src/renderer/src/App.tsx Outdated
Comment on lines +2396 to +2400
setLoginError(error instanceof Error ? error.message : "Failed to switch account");
await refreshSavedAccounts();
const sessionResult = await window.openNow.getAuthSession();
setAuthSession(sessionResult.session);
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +369 to +413
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}
>
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread opennow-stable/src/main/gfn/auth.ts Outdated
Comment on lines +615 to +622
getSavedAccounts(): Array<{
userId: string;
displayName: string;
email?: string;
avatarUrl?: string;
membershipTier: string;
providerCode: string;
}> {
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 uses AI. Check for mistakes.
@PriyanshAg-1
Copy link
Copy Markdown

@copilot apply changes based on the comments in this thread

PriyanshAg-1 and others added 3 commits April 22, 2026 18:18
…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>
PriyanshAg-1 and others added 3 commits April 23, 2026 00:29
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

type="button"
className="navbar-account-remove"
aria-label={`Remove ${account.displayName}`}
onClick={() => {
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
onClick={() => {
onClick={() => {
setAccountDropdownOpen(false);

Copilot uses AI. Check for mistakes.
Comment thread opennow-stable/src/renderer/src/App.tsx Outdated

void initialize();
}, []);
}, [catalogFilterKey, catalogSelectedSortId, loadSessionRuntimeData]);
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}, [catalogFilterKey, catalogSelectedSortId, loadSessionRuntimeData]);
}, []);

Copilot uses AI. Check for mistakes.
Comment thread opennow-stable/src/renderer/src/App.tsx Outdated
Comment on lines 2442 to 2447
const confirmLogout = useCallback(async () => {
setLogoutConfirmOpen(false);
await window.openNow.logout();
setAuthSession(null);
setSavedAccounts([]);
setGames([]);
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 1108 to 1137
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: {
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 1028 to +1030
ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async () => {
await authService.logout();
await authService.logoutAll();
});
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +390 to +392
{accountDropdownOpen && (
<div className="navbar-account-dropdown" role="menu" aria-label="Switch account">
<div className="navbar-account-dropdown-header">Switch Account</div>
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
PriyanshAg-1 and others added 5 commits April 23, 2026 01:26
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>
Copy link
Copy Markdown
Collaborator

@Kief5555 Kief5555 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please conduct testing and provide a video when the errors have been fixed.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 returns session: this.getSession(), which may now refer to a different saved account. Returning a different account's session here can cause unintended account switching. Suggest returning session: 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, ensureValidSessionWithStatus calls await this.logout() and then returns session: this.getSession(). With multi-account, logout() can advance activeUserId to a different saved account, so this method can unexpectedly return a different user's session alongside an error about the expired one. Consider returning session: 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.",
          },
        };

Comment on lines 4 to +9
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",
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines 62 to +71
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),
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +476 to +485
<button
type="button"
className="navbar-account-signout-all"
onClick={() => {
onLogout();
setAccountDropdownOpen(false);
}}
>
Sign out all accounts
</button>
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread opennow-stable/src/main/gfn/auth.ts Outdated
Comment on lines +1011 to +1037
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;
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}`,
},
};
}

Copilot uses AI. Check for mistakes.
Comment on lines 712 to +717
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>;
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 1028 to +1042
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);
});
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +133 to +141
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);
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +365 to +401
<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">
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants