diff --git a/opennow-stable/src/main/gfn/auth.ts b/opennow-stable/src/main/gfn/auth.ts index 03f6331..6df3e08 100644 --- a/opennow-stable/src/main/gfn/auth.ts +++ b/opennow-stable/src/main/gfn/auth.ts @@ -14,6 +14,7 @@ import type { AuthTokens, AuthUser, LoginProvider, + SavedAccount, StreamRegion, SubscriptionInfo, } from "@shared/gfn"; @@ -37,7 +38,8 @@ const TOKEN_REFRESH_WINDOW_MS = 10 * 60 * 1000; const CLIENT_TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000; interface PersistedAuthState { - session: AuthSession | null; + sessions: AuthSession[]; + activeUserId: string | null; selectedProvider: LoginProvider | null; } @@ -434,7 +436,8 @@ async function fetchUserInfo(tokens: AuthTokens): Promise { export class AuthService { private providers: LoginProvider[] = []; - private session: AuthSession | null = null; + private sessions = new Map(); + private activeUserId: string | null = null; private selectedProvider: LoginProvider = defaultProvider(); private cachedSubscription: SubscriptionInfo | null = null; private cachedVpcId: string | null = null; @@ -452,23 +455,46 @@ export class AuthService { try { const raw = await readFile(this.statePath, "utf8"); - const parsed = JSON.parse(raw) as PersistedAuthState; + const parsed = JSON.parse(raw) as Partial & { + session?: AuthSession | null; + }; if (parsed.selectedProvider) { this.selectedProvider = normalizeProvider(parsed.selectedProvider); } - if (parsed.session) { - this.session = { + + this.sessions.clear(); + if (Array.isArray(parsed.sessions)) { + for (const persistedSession of parsed.sessions) { + if (!persistedSession?.user?.userId) { + continue; + } + this.sessions.set(persistedSession.user.userId, { + ...persistedSession, + provider: normalizeProvider(persistedSession.provider), + }); + } + } else if (parsed.session?.user?.userId) { + this.sessions.set(parsed.session.user.userId, { ...parsed.session, provider: normalizeProvider(parsed.session.provider), - }; + }); + } + + if (typeof parsed.activeUserId === "string" && this.sessions.has(parsed.activeUserId)) { + this.activeUserId = parsed.activeUserId; + } else { + this.activeUserId = this.sessions.keys().next().value ?? null; + } - // Refresh the real tier from MES API on session restore - // (persisted tier may be stale or was "FREE" from JWT fallback) + const restoredSession = this.getSession(); + if (restoredSession) { + this.selectedProvider = restoredSession.provider; await this.enrichUserTier(); await this.persist(); } } catch { - this.session = null; + this.sessions.clear(); + this.activeUserId = null; this.selectedProvider = defaultProvider(); await this.persist(); } @@ -476,7 +502,8 @@ export class AuthService { private async persist(): Promise { const payload: PersistedAuthState = { - session: this.session, + sessions: Array.from(this.sessions.values()), + activeUserId: this.activeUserId, selectedProvider: this.selectedProvider, }; @@ -556,12 +583,100 @@ export class AuthService { } } + setSession(session: AuthSession | null): void { + if (!session) { + this.sessions.clear(); + this.activeUserId = null; + this.selectedProvider = defaultProvider(); + this.clearSubscriptionCache(); + this.clearVpcCache(); + void this.persist(); + return; + } + + const normalized: AuthSession = { + ...session, + provider: normalizeProvider(session.provider), + }; + this.sessions.set(normalized.user.userId, normalized); + this.activeUserId = normalized.user.userId; + this.selectedProvider = normalized.provider; + this.clearSubscriptionCache(); + this.clearVpcCache(); + void this.persist(); + } + getSession(): AuthSession | null { - return this.session; + if (!this.activeUserId) { + return null; + } + return this.sessions.get(this.activeUserId) ?? null; + } + + getSavedAccounts(): SavedAccount[] { + return Array.from(this.sessions.values()).map((session) => ({ + userId: session.user.userId, + displayName: session.user.displayName, + email: session.user.email, + avatarUrl: session.user.avatarUrl, + membershipTier: session.user.membershipTier, + providerCode: session.provider.code, + })); + } + + async switchAccount(userId: string): Promise { + const target = this.sessions.get(userId); + if (!target) { + throw new Error("Saved account not found"); + } + this.activeUserId = userId; + this.selectedProvider = target.provider; + this.clearSubscriptionCache(); + this.clearVpcCache(); + + const result = await this.ensureValidSessionWithStatus(true, userId); + const refreshFailed = + result.refresh.outcome === "failed" || result.refresh.outcome === "missing_refresh_token"; + const switchedUserMismatch = result.session?.user.userId !== userId; + if (!result.session || refreshFailed || switchedUserMismatch) { + await this.removeAccount(userId); + const fallbackMessage = "Failed to switch account due to an invalid or expired session."; + if (switchedUserMismatch) { + throw new Error("Switched session did not match the selected account."); + } + if (result.refresh.outcome === "missing_refresh_token") { + throw new Error("Saved login for this account is incomplete. Please log in to this account again."); + } + throw new Error(result.refresh.message || fallbackMessage); + } + return result.session; + } + + async removeAccount(userId: string): Promise { + const removed = this.sessions.delete(userId); + if (!removed) { + return; + } + if (this.activeUserId === userId) { + this.activeUserId = this.sessions.keys().next().value ?? null; + } + this.selectedProvider = this.getSession()?.provider ?? defaultProvider(); + this.clearSubscriptionCache(); + this.clearVpcCache(); + await this.persist(); + } + + async logoutAll(): Promise { + this.sessions.clear(); + this.activeUserId = null; + this.selectedProvider = defaultProvider(); + this.cachedSubscription = null; + this.clearVpcCache(); + await this.persist(); } getSelectedProvider(): LoginProvider { - return this.selectedProvider; + return this.getSession()?.provider ?? this.selectedProvider; } async getRegions(explicitToken?: string): Promise { @@ -645,22 +760,32 @@ export class AuthService { console.warn("Unable to fetch client token after login. Falling back to OAuth token only:", error); } - this.session = { + const nextSession: AuthSession = { provider: this.selectedProvider, tokens, user, }; + this.sessions.set(user.userId, nextSession); + this.activeUserId = user.userId; + this.selectedProvider = nextSession.provider; + this.clearSubscriptionCache(); + this.clearVpcCache(); // Fetch real membership tier from MES subscription API // (JWT does not contain gfn_tier, so fetchUserInfo always falls back to "FREE") await this.enrichUserTier(); await this.persist(); - return this.session; + return this.getSession() as AuthSession; } async logout(): Promise { - this.session = null; + if (!this.activeUserId) { + return; + } + this.sessions.delete(this.activeUserId); + this.activeUserId = this.sessions.keys().next().value ?? null; + this.selectedProvider = this.getSession()?.provider ?? defaultProvider(); this.cachedSubscription = null; this.clearVpcCache(); await this.persist(); @@ -685,7 +810,7 @@ export class AuthService { const userId = session.user.userId; // Fetch dynamic regions to get the VPC ID (handles Alliance partners correctly) - const { vpcId } = await fetchDynamicRegions(token, this.selectedProvider.streamingServiceUrl); + const { vpcId } = await fetchDynamicRegions(token, session.provider.streamingServiceUrl); const subscription = await fetchSubscription(token, userId, vpcId ?? undefined); this.cachedSubscription = subscription; @@ -789,18 +914,19 @@ export class AuthService { * Falls back silently to the existing tier if the fetch fails. */ private async enrichUserTier(): Promise { - if (!this.session) return; + const session = this.getSession(); + if (!session) return; try { const subscription = await this.getSubscription(); if (subscription && subscription.membershipTier) { - this.session = { - ...this.session, + this.sessions.set(session.user.userId, { + ...session, user: { - ...this.session.user, + ...session.user, membershipTier: subscription.membershipTier, }, - }; + }); console.log(`Resolved membership tier: ${subscription.membershipTier}`); } } catch (error) { @@ -812,8 +938,12 @@ export class AuthService { return isNearExpiry(tokens.expiresAt, TOKEN_REFRESH_WINDOW_MS); } - async ensureValidSessionWithStatus(forceRefresh = false): Promise { - if (!this.session) { + async ensureValidSessionWithStatus( + forceRefresh = false, + expectedUserId?: string, + ): Promise { + const currentSession = this.getSession(); + if (!currentSession) { return { session: null, refresh: { @@ -825,8 +955,8 @@ export class AuthService { }; } - const userId = this.session.user.userId; - let tokens = this.session.tokens; + const userId = currentSession.user.userId; + let tokens = currentSession.tokens; // Official GFN client flow relies on client_token-based refresh. Bootstrap it // for older sessions that were saved before we persisted client tokens. @@ -834,10 +964,10 @@ export class AuthService { try { const withClientToken = await this.ensureClientToken(tokens, userId); if (withClientToken.clientToken && withClientToken.clientToken !== tokens.clientToken) { - this.session = { - ...this.session, + this.sessions.set(userId, { + ...currentSession, tokens: withClientToken, - }; + }); tokens = withClientToken; await this.persist(); } @@ -849,7 +979,7 @@ export class AuthService { const shouldRefreshNow = forceRefresh || this.shouldRefresh(tokens); if (!shouldRefreshNow) { return { - session: this.session, + session: this.getSession(), refresh: { attempted: false, forced: forceRefresh, @@ -863,19 +993,54 @@ export class AuthService { refreshedTokens: AuthTokens, source: "client_token" | "refresh_token", ): Promise => { - let user = this.session?.user; + const latestSession = this.getSession() ?? currentSession; + let refreshedUser: AuthUser | null = null; + let userInfoError: string | undefined; try { - user = await fetchUserInfo(refreshedTokens); - console.debug("auth: fetched user info on token refresh", { userId: user.userId, email: user.email, avatarUrl: user.avatarUrl }); + refreshedUser = await fetchUserInfo(refreshedTokens); + console.debug("auth: fetched user info on token refresh", { + userId: refreshedUser.userId, + email: refreshedUser.email, + avatarUrl: refreshedUser.avatarUrl, + }); } catch (error) { console.warn("Token refresh succeeded but user info refresh failed. Keeping cached user:", error); + userInfoError = error instanceof Error ? error.message : "Unknown error while fetching user info"; } - this.session = { - provider: this.session!.provider, + 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 updatedSession: AuthSession = { + provider: latestSession.provider, tokens: refreshedTokens, - user: user ?? this.session!.user, + user: resolvedUser, }; + this.sessions.set(updatedSession.user.userId, updatedSession); // Re-fetch real tier after token refresh this.clearSubscriptionCache(); @@ -884,7 +1049,7 @@ export class AuthService { const sourceText = source === "client_token" ? "client token" : "refresh token"; return { - session: this.session, + session: this.getSession(), refresh: { attempted: true, forced: forceRefresh, @@ -938,7 +1103,7 @@ export class AuthService { if (expired) { await this.logout(); return { - session: null, + session: this.getSession(), refresh: { attempted: true, forced: forceRefresh, @@ -949,7 +1114,7 @@ export class AuthService { } return { - session: this.session, + session: this.getSession(), refresh: { attempted: true, forced: forceRefresh, @@ -962,7 +1127,7 @@ export class AuthService { if (expired) { await this.logout(); return { - session: null, + session: this.getSession(), refresh: { attempted: true, forced: forceRefresh, @@ -974,7 +1139,7 @@ export class AuthService { } return { - session: this.session, + session: this.getSession(), refresh: { attempted: true, forced: forceRefresh, @@ -993,7 +1158,7 @@ export class AuthService { async resolveJwtToken(explicitToken?: string): Promise { // Prefer the managed auth session whenever it exists so renderer-side cached // tokens cannot bypass refresh logic. - if (this.session) { + if (this.getSession()) { const session = await this.ensureValidSession(); if (!session) { throw new Error("No authenticated session available"); diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 14e59d1..e96c99a 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1026,7 +1026,19 @@ function registerIpcHandlers(): void { }); ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT, async () => { - await authService.logout(); + await authService.logoutAll(); + }); + + 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); }); ipcMain.handle(IPC_CHANNELS.SUBSCRIPTION_FETCH, async (_event, payload: SubscriptionFetchRequest) => { diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index b633d73..5420156 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -3,6 +3,7 @@ import electron from "electron"; import { IPC_CHANNELS } from "@shared/ipc"; import type { AuthLoginRequest, + AuthSession, AuthSessionRequest, GamesFetchRequest, CatalogBrowseRequest, @@ -10,6 +11,7 @@ import type { RegionsFetchRequest, MainToRendererSignalingEvent, OpenNowApi, + SavedAccount, SessionAdReportRequest, SessionCreateRequest, SessionPollRequest, @@ -63,6 +65,10 @@ const api: OpenNowApi = { 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 => ipcRenderer.invoke(IPC_CHANNELS.AUTH_GET_SAVED_ACCOUNTS), + switchAccount: (userId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.AUTH_SWITCH_ACCOUNT, userId), + removeAccount: (userId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.AUTH_REMOVE_ACCOUNT, userId), fetchSubscription: (input: SubscriptionFetchRequest) => ipcRenderer.invoke(IPC_CHANNELS.SUBSCRIPTION_FETCH, input), fetchMainGames: (input: GamesFetchRequest) => ipcRenderer.invoke(IPC_CHANNELS.GAMES_FETCH_MAIN, input), diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 0fe45a2..38861b7 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -19,6 +19,7 @@ import type { SessionAdState, SessionInfo, SessionStopRequest, + SavedAccount, Settings, SubscriptionInfo, StreamRegion, @@ -775,6 +776,7 @@ export function App(): JSX.Element { // Auth State const [authSession, setAuthSession] = useState(null); + const [savedAccounts, setSavedAccounts] = useState([]); const [providers, setProviders] = useState([]); const [providerIdpId, setProviderIdpId] = useState(""); const [isLoggingIn, setIsLoggingIn] = useState(false); @@ -875,6 +877,8 @@ export function App(): JSX.Element { const [navbarActiveSession, setNavbarActiveSession] = useState(null); const [isResumingNavbarSession, setIsResumingNavbarSession] = useState(false); const [isTerminatingNavbarSession, setIsTerminatingNavbarSession] = useState(false); + const [accountToRemove, setAccountToRemove] = useState(null); + const [removeAccountConfirmOpen, setRemoveAccountConfirmOpen] = useState(false); const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); const [launchError, setLaunchError] = useState(null); const [queueModalGame, setQueueModalGame] = useState(null); @@ -1676,18 +1680,26 @@ export function App(): JSX.Element { [], ); - const refreshNavbarActiveSession = useCallback(async (): Promise => { - if (!authSession) { + const refreshSavedAccounts = useCallback(async (): Promise => { + const accounts = await window.openNow.getSavedAccounts(); + setSavedAccounts(accounts); + return accounts; + }, []); + + const refreshNavbarActiveSession = useCallback(async (sessionOverride?: AuthSession | null): Promise => { + const session = sessionOverride ?? authSession; + if (!session) { setNavbarActiveSession(null); return; } - const token = authSession.tokens.idToken ?? authSession.tokens.accessToken; - if (!token || !effectiveStreamingBaseUrl) { + const token = session.tokens.idToken ?? session.tokens.accessToken; + const streamingBaseUrl = sessionOverride?.provider.streamingServiceUrl ?? effectiveStreamingBaseUrl; + if (!token || !streamingBaseUrl) { setNavbarActiveSession(null); return; } try { - const activeSessions = await window.openNow.getActiveSessions(token, effectiveStreamingBaseUrl); + const activeSessions = await window.openNow.getActiveSessions(token, streamingBaseUrl); const candidate = activeSessions.find((entry) => entry.status === 3 || entry.status === 2) ?? null; setNavbarActiveSession(candidate); } catch (error) { @@ -1781,9 +1793,10 @@ export function App(): JSX.Element { // Load providers and session (refresh only if token is near expiry) setStartupStatusMessage("Restoring saved session..."); - const [providerList, sessionResult] = await Promise.all([ + const [providerList, sessionResult, accounts] = await Promise.all([ window.openNow.getLoginProviders(), window.openNow.getAuthSession(), + window.openNow.getSavedAccounts(), ]); const persistedSession = sessionResult.session; @@ -1824,51 +1837,15 @@ export function App(): JSX.Element { setIsInitializing(false); setProviders(providerList); setAuthSession(persistedSession); + setSavedAccounts(accounts); const activeProviderId = persistedSession?.provider?.idpId ?? providerList[0]?.idpId ?? ""; setProviderIdpId(activeProviderId); if (persistedSession) { - // Load regions - const token = persistedSession.tokens.idToken ?? persistedSession.tokens.accessToken; - const discovered = await window.openNow.getRegions({ token }); - setRegions(discovered); - - try { - await loadSubscriptionInfo(persistedSession); - } catch (error) { - console.warn("Failed to load subscription info:", error); - setSubscriptionInfo(null); - } - - // Load games - try { - const [catalogResult, libGames] = await Promise.all([ - window.openNow.browseCatalog({ - token, - providerStreamingBaseUrl: persistedSession.provider.streamingServiceUrl, - searchQuery: "", - sortId: catalogSelectedSortId, - filterIds: catalogSelectedFilterIds, - }), - window.openNow.fetchLibraryGames({ - token, - providerStreamingBaseUrl: persistedSession.provider.streamingServiceUrl, - }), - ]); - applyCatalogBrowseResult(catalogResult); - setLibraryGames(libGames); - applyVariantSelections(libGames); - } catch (catalogError) { - console.error("Initialization games load failed:", catalogError); - setGames([]); - setLibraryGames([]); - setCatalogFilterGroups([]); - setCatalogSortOptions([]); - setCatalogTotalCount(0); - setCatalogSupportedCount(0); - } + await loadSessionRuntimeData(persistedSession); } else { + setRegions([]); setGames([]); setLibraryGames([]); setSubscriptionInfo(null); @@ -1886,7 +1863,7 @@ export function App(): JSX.Element { }; void initialize(); - }, []); + }, [catalogFilterKey, catalogSelectedSortId, loadSessionRuntimeData]); useEffect(() => { saveStoredCodecResults(codecResults); @@ -2351,27 +2328,34 @@ export function App(): JSX.Element { applyVariantSelections(catalogResult.games); }, [applyVariantSelections]); - // Login handler - const handleLogin = useCallback(async () => { - setIsLoggingIn(true); - setLoginError(null); - try { - const session = await window.openNow.login({ providerIdpId: providerIdpId || undefined }); - setAuthSession(session); - setProviderIdpId(session.provider.idpId); + const clearSessionScopedState = useCallback((): void => { + setRegions([]); + setGames([]); + setLibraryGames([]); + setSubscriptionInfo(null); + setCatalogFilterGroups([]); + setCatalogSortOptions([]); + setCatalogTotalCount(0); + setCatalogSupportedCount(0); + setSelectedGameId(""); + setNavbarActiveSession(null); + setIsResumingNavbarSession(false); + setIsTerminatingNavbarSession(false); + }, []); - // Load regions - const token = session.tokens.idToken ?? session.tokens.accessToken; - const discovered = await window.openNow.getRegions({ token }); - setRegions(discovered); + async function loadSessionRuntimeData(session: AuthSession): Promise { + const token = session.tokens.idToken ?? session.tokens.accessToken; + const discovered = await window.openNow.getRegions({ token }); + setRegions(discovered); - try { - await loadSubscriptionInfo(session); - } catch (error) { - console.warn("Failed to load subscription info:", error); - setSubscriptionInfo(null); - } + try { + await loadSubscriptionInfo(session); + } catch (error) { + console.warn("Failed to load subscription info:", error); + setSubscriptionInfo(null); + } + try { const [catalogResult, libGames] = await Promise.all([ window.openNow.browseCatalog({ token, @@ -2388,17 +2372,95 @@ 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); + } + } + + // Login handler + const handleLogin = useCallback(async () => { + setIsLoggingIn(true); + setLoginError(null); + try { + const session = await window.openNow.login({ providerIdpId: providerIdpId || undefined }); + setAuthSession(session); + setProviderIdpId(session.provider.idpId); + await refreshSavedAccounts(); + await loadSessionRuntimeData(session); } catch (error) { setLoginError(error instanceof Error ? error.message : "Login failed"); } finally { setIsLoggingIn(false); } - }, [applyCatalogBrowseResult, applyVariantSelections, loadSubscriptionInfo, providerIdpId, catalogFilterKey, catalogSelectedSortId]); + }, [loadSessionRuntimeData, providerIdpId, refreshSavedAccounts]); + + 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(session); + } catch (error) { + console.warn("Failed to switch account:", error); + setLoginError(error instanceof Error ? error.message : "Failed to switch account"); + await refreshSavedAccounts(); + const sessionResult = await window.openNow.getAuthSession(); + const recoveredSession = sessionResult.session; + setAuthSession(recoveredSession); + if (recoveredSession) { + setProviderIdpId(recoveredSession.provider.idpId); + await loadSessionRuntimeData(recoveredSession); + await refreshNavbarActiveSession(recoveredSession); + } else { + clearSessionScopedState(); + } + } + }, [clearSessionScopedState, loadSessionRuntimeData, refreshNavbarActiveSession, refreshSavedAccounts]); + + const handleRemoveAccount = useCallback((userId: string) => { + setAccountToRemove(userId); + setRemoveAccountConfirmOpen(true); + }, []); + + const confirmRemoveAccount = useCallback(async () => { + if (!accountToRemove) return; + const targetUserId = accountToRemove; + setRemoveAccountConfirmOpen(false); + setAccountToRemove(null); + + await window.openNow.removeAccount(targetUserId); + const [accounts, sessionResult] = await Promise.all([ + window.openNow.getSavedAccounts(), + window.openNow.getAuthSession(), + ]); + setSavedAccounts(accounts); + setAuthSession(sessionResult.session); + if (sessionResult.session) { + setProviderIdpId(sessionResult.session.provider.idpId); + await loadSessionRuntimeData(sessionResult.session); + return; + } + clearSessionScopedState(); + }, [accountToRemove, clearSessionScopedState, loadSessionRuntimeData]); + + const handleAddAccount = useCallback(() => { + setAuthSession(null); + setLoginError(null); + }, []); const confirmLogout = useCallback(async () => { setLogoutConfirmOpen(false); await window.openNow.logout(); setAuthSession(null); + setSavedAccounts([]); setGames([]); setLibraryGames([]); setVariantByGameId({}); @@ -2904,15 +2966,24 @@ export function App(): JSX.Element { }, []); useEffect(() => { - if (!logoutConfirmOpen) return; + if (!logoutConfirmOpen && !removeAccountConfirmOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - setLogoutConfirmOpen(false); + if (removeAccountConfirmOpen) { + setRemoveAccountConfirmOpen(false); + setAccountToRemove(null); + } else if (logoutConfirmOpen) { + setLogoutConfirmOpen(false); + } } if (event.key === "Enter") { event.preventDefault(); - void confirmLogout(); + if (removeAccountConfirmOpen) { + void confirmRemoveAccount(); + } else if (logoutConfirmOpen) { + void confirmLogout(); + } } }; @@ -2924,7 +2995,11 @@ export function App(): JSX.Element { window.removeEventListener("keydown", handleKeyDown); document.body.style.overflow = previousOverflow; }; - }, [confirmLogout, logoutConfirmOpen]); + }, [confirmLogout, confirmRemoveAccount, logoutConfirmOpen, removeAccountConfirmOpen]); + + const accountToRemoveDisplayName = useMemo(() => ( + savedAccounts.find((account) => account.userId === accountToRemove)?.displayName ?? "this account" + ), [accountToRemove, savedAccounts]); const logoutConfirmModal = logoutConfirmOpen && typeof document !== "undefined" ? createPortal( @@ -2971,6 +3046,57 @@ export function App(): JSX.Element { ) : null; + const removeAccountConfirmModal = removeAccountConfirmOpen && typeof document !== "undefined" + ? createPortal( +
+ + +
+
+ Enter confirm ยท Esc cancel +
+ + , + document.body, + ) + : null; + const handleResumeFromNavbar = useCallback(async () => { if ( !selectedProvider @@ -3716,6 +3842,12 @@ export function App(): JSX.Element { onTerminateSession={() => { void handleTerminateNavbarSession(); }} + savedAccounts={savedAccounts} + onSwitchAccount={handleSwitchAccount} + onRemoveAccount={(userId) => { + void handleRemoveAccount(userId); + }} + onAddAccount={handleAddAccount} onLogout={handleLogout} /> )} @@ -3833,6 +3965,7 @@ export function App(): JSX.Element { )} {logoutConfirmModal} + {removeAccountConfirmModal} {queueModalGame && streamStatus === "idle" && ( void; onTerminateSession: () => void; + savedAccounts: SavedAccount[]; + onSwitchAccount: (userId: string) => void; + onRemoveAccount: (userId: string) => void; + onAddAccount: () => void; onLogout: () => void; } @@ -37,9 +41,15 @@ export function Navbar({ isTerminatingSession, onResumeSession, onTerminateSession, + savedAccounts, + onSwitchAccount, + onRemoveAccount, + onAddAccount, onLogout, }: NavbarProps): JSX.Element { const [modalType, setModalType] = useState(null); + const [accountDropdownOpen, setAccountDropdownOpen] = useState(false); + const accountContainerRef = useRef(null); const navItems = [ { id: "home" as const, label: "Store", icon: House }, @@ -118,6 +128,18 @@ export function Navbar({ const firstEntitlementStart = formatDateTime(subscription?.firstEntitlementStartDateTime); const modalTitle = modalType === "time" ? "Playtime Details" : "Storage Details"; const activeSessionTitle = activeSessionGameTitle?.trim() || null; + const activeUserId = user?.userId ?? null; + + 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); + }, [accountDropdownOpen]); useEffect(() => { if (!modalType) return; @@ -339,24 +361,123 @@ export function Navbar({ )} {user ? ( <> -
- {user.avatarUrl ? ( - {user.displayName} - ) : ( -
- +
+ + {accountDropdownOpen && ( +
+
Switch Account
+
+ {savedAccounts.map((account) => { + const accountTierInfo = getTierDisplay(account.membershipTier); + const isActive = activeUserId === account.userId; + const canRemove = !isActive && savedAccounts.length > 1; + return ( +
+ + {canRemove && ( + + )} +
+ ); + })} +
+
+ +
)} -
- {user.displayName} - {tierInfo && ( - {tierInfo.label} - )} -
- ) : (
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index d9a2077..b137072 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -609,6 +609,32 @@ body, border-radius: 6px; } +.navbar-account-container { + position: relative; +} + +.navbar-user--clickable { + border: none; + background: transparent; + font-family: inherit; + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); +} + +.navbar-user--clickable:hover { + background: rgba(255, 255, 255, 0.05); +} + +.navbar-user-chevron { + color: var(--ink-muted); + transition: transform var(--t-normal), color var(--t-fast); +} + +.navbar-user-chevron.is-open { + transform: rotate(180deg); + color: var(--ink-soft); +} + .navbar-avatar { width: 24px; height: 24px; @@ -664,6 +690,195 @@ body, color: var(--ink-muted); } +.navbar-account-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 320px; + max-height: 420px; + display: flex; + flex-direction: column; + border-radius: var(--r-md); + border: 1px solid var(--panel-border); + background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 88%, black), var(--panel)); + box-shadow: var(--shadow-md); + z-index: 1100; + overflow: hidden; + animation: fade-in 120ms var(--ease); +} + +.navbar-account-dropdown-header { + padding: 10px 12px 8px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.navbar-account-list { + max-height: 230px; + overflow-y: auto; + padding: 0 6px 6px; +} + +.navbar-account-item { + display: flex; + align-items: center; + gap: 6px; + border-radius: var(--r-sm); + transition: background var(--t-fast); +} + +.navbar-account-item--active { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +.navbar-account-item-main { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + border: none; + background: transparent; + border-radius: var(--r-sm); + color: inherit; + font-family: inherit; + text-align: left; + padding: 8px 8px; + cursor: pointer; + transition: background var(--t-fast); +} + +.navbar-account-item-main:hover:not(:disabled) { + background: color-mix(in srgb, var(--bg-c) 76%, transparent); +} + +.navbar-account-item-main:disabled { + cursor: default; +} + +.navbar-account-item-avatar { + width: 20px; + height: 20px; + border-radius: 5px; + object-fit: cover; + flex-shrink: 0; +} + +.navbar-account-item-info { + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.navbar-account-item-name { + color: var(--ink); + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.navbar-account-item-email { + color: var(--ink-muted); + font-size: 0.68rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.navbar-account-item-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.navbar-account-item-tier { + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.navbar-account-item-tier.tier-ultimate { + color: #c084fc; +} + +.navbar-account-item-tier.tier-priority { + color: var(--success); +} + +.navbar-account-item-tier.tier-free { + color: var(--ink-muted); +} + +.navbar-account-item-check { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.navbar-account-remove { + width: 22px; + height: 22px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--ink-muted); + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); +} + +.navbar-account-remove:hover { + background: color-mix(in srgb, var(--error) 18%, transparent); + color: var(--error); +} + +.navbar-account-divider { + height: 1px; + margin: 2px 10px 8px; + background: var(--panel-border); +} + +.navbar-account-add, +.navbar-account-signout-all { + display: inline-flex; + align-items: center; + gap: 8px; + width: calc(100% - 12px); + margin: 0 6px; + border: none; + border-radius: var(--r-sm); + background: transparent; + padding: 8px 10px; + font-family: inherit; + font-size: 0.78rem; + color: var(--ink-soft); + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); +} + +.navbar-account-add:hover { + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--ink); +} + +.navbar-account-signout-all { + margin-bottom: 8px; +} + +.navbar-account-signout-all:hover { + background: color-mix(in srgb, var(--error) 14%, transparent); + color: var(--error); +} + .navbar-logout { display: flex; align-items: center; diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 005980c..5c4b121 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -264,6 +264,15 @@ export interface AuthSession { user: AuthUser; } +export interface SavedAccount { + userId: string; + displayName: string; + email?: string; + avatarUrl?: string; + membershipTier: string; + providerCode: string; +} + export interface ThankYouContributor { login: string; avatarUrl: string; @@ -703,6 +712,9 @@ export interface OpenNowApi { getRegions(input?: RegionsFetchRequest): Promise; login(input: AuthLoginRequest): Promise; logout(): Promise; + getSavedAccounts(): Promise; + switchAccount(userId: string): Promise; + removeAccount(userId: string): Promise; fetchSubscription(input: SubscriptionFetchRequest): Promise; fetchMainGames(input: GamesFetchRequest): Promise; fetchLibraryGames(input: GamesFetchRequest): Promise; diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index 772ffb1..e2039cc 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -4,6 +4,9 @@ export const IPC_CHANNELS = { 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", PING_REGIONS: "gfn:ping-regions", SUBSCRIPTION_FETCH: "subscription:fetch", GAMES_FETCH_MAIN: "games:fetch-main",