diff --git a/opennow-stable/src/main/gfn/auth.ts b/opennow-stable/src/main/gfn/auth.ts index 03f6331b4..e21ab0722 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), - }; + }); + } - // Refresh the real tier from MES API on session restore - // (persisted tier may be stale or was "FREE" from JWT fallback) + if (typeof parsed.activeUserId === "string" && this.sessions.has(parsed.activeUserId)) { + this.activeUserId = parsed.activeUserId; + } else { + this.activeUserId = this.sessions.keys().next().value ?? null; + } + + 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,123 @@ 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; + } + + private setActiveAccount(userId: string | null): void { + this.activeUserId = userId && this.sessions.has(userId) ? userId : null; + this.selectedProvider = this.getSession()?.provider ?? defaultProvider(); + this.clearSubscriptionCache(); + this.clearVpcCache(); + } + + 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"); + } + + const previousActiveUserId = this.activeUserId; + const previousSelectedProvider = this.selectedProvider; + + this.activeUserId = userId; + this.selectedProvider = target.provider; + this.clearSubscriptionCache(); + this.clearVpcCache(); + + const result = await this.ensureValidSessionWithStatus(true, userId); + const missingRefreshToken = result.refresh.outcome === "missing_refresh_token"; + const refreshFailed = result.refresh.outcome === "failed"; + const switchedUserMismatch = result.session?.user.userId !== userId; + if (!result.session || refreshFailed || missingRefreshToken || switchedUserMismatch) { + const fallbackMessage = "Failed to switch account due to an invalid or expired session."; + + if (missingRefreshToken) { + await this.removeAccount(userId); + this.setActiveAccount(previousActiveUserId); + await this.persist(); + throw new Error("Saved login for this account is incomplete. Please log in to this account again."); + } + + this.activeUserId = previousActiveUserId; + this.selectedProvider = previousActiveUserId && this.sessions.has(previousActiveUserId) + ? previousSelectedProvider + : this.getSession()?.provider ?? defaultProvider(); + this.clearSubscriptionCache(); + this.clearVpcCache(); + await this.persist(); + + if (switchedUserMismatch) { + throw new Error("Switched session did not match the selected account."); + } + 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.setActiveAccount(this.sessions.keys().next().value ?? null); + } else { + 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 +783,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 +833,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 +937,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 +961,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 +978,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 +987,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 +1002,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 +1016,49 @@ export class AuthService { refreshedTokens: AuthTokens, source: "client_token" | "refresh_token", ): Promise => { - let user = this.session?.user; + const latestSession = this.getSession() ?? currentSession; + const baseSession = latestSession.user.userId === userId ? latestSession : currentSession; + const expectedRefreshUserId = expectedUserId ?? userId; + 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"; + } + + const resolvedUser = refreshedUser ?? baseSession.user; + if (resolvedUser.userId !== expectedRefreshUserId) { + return { + session: baseSession, + 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:${expectedRefreshUserId} actual_user_id:${refreshedUser.userId}` + : userInfoError + ? `expected_user_id:${expectedRefreshUserId} cached_user_id:${resolvedUser.userId} user_info_error:${userInfoError}` + : `expected_user_id:${expectedRefreshUserId} cached_user_id:${resolvedUser.userId}`, + }, + }; } - this.session = { - provider: this.session!.provider, + const updatedSession: AuthSession = { + provider: baseSession.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 +1067,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, @@ -949,7 +1132,7 @@ export class AuthService { } return { - session: this.session, + session: this.getSession(), refresh: { attempted: true, forced: forceRefresh, @@ -974,7 +1157,7 @@ export class AuthService { } return { - session: this.session, + session: this.getSession(), refresh: { attempted: true, forced: forceRefresh, @@ -993,7 +1176,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 23c01e452..d7ffc0ff9 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1112,6 +1112,22 @@ function registerIpcHandlers(): void { await authService.logout(); }); + ipcMain.handle(IPC_CHANNELS.AUTH_LOGOUT_ALL, async () => { + 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) => { const token = await resolveJwt(payload?.token); const streamingBaseUrl = diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 778cf14c8..b68c21b70 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,11 @@ 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), + logoutAll: () => ipcRenderer.invoke(IPC_CHANNELS.AUTH_LOGOUT_ALL), + 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 2dbd19bba..d01268a3d 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, @@ -777,6 +778,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); @@ -878,6 +880,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); @@ -1717,24 +1721,36 @@ 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, + streamingBaseUrlOverride?: string, + ): 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 = streamingBaseUrlOverride + ?? (settings.region.trim() ? effectiveStreamingBaseUrl : session.provider.streamingServiceUrl); + 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) { console.warn("Failed to refresh active sessions:", error); } - }, [authSession, effectiveStreamingBaseUrl]); + }, [authSession, effectiveStreamingBaseUrl, settings.region]); const allKnownGames = useMemo(() => [...games, ...libraryGames], [games, libraryGames]); @@ -1807,128 +1823,6 @@ export function App(): JSX.Element { return () => window.clearInterval(timer); }, [authSession, refreshNavbarActiveSession, streamStatus]); - // Initialize app - useEffect(() => { - if (hasInitializedRef.current) return; - hasInitializedRef.current = true; - - const initialize = async () => { - try { - // Load settings first - const loadedSettings = await window.openNow.getSettings(); - setSettings(loadedSettings); - setShowStatsOverlay(loadedSettings.showStatsOnLaunch); - setSettingsLoaded(true); - - // Load providers and session (refresh only if token is near expiry) - setStartupStatusMessage("Restoring saved session..."); - const [providerList, sessionResult] = await Promise.all([ - window.openNow.getLoginProviders(), - window.openNow.getAuthSession(), - ]); - const persistedSession = sessionResult.session; - - if (sessionResult.refresh.outcome === "refreshed") { - setStartupRefreshNotice({ - tone: "success", - text: "Session restored. Token refreshed.", - }); - setStartupStatusMessage("Token refreshed. Loading your account..."); - } else if (sessionResult.refresh.outcome === "failed") { - setStartupRefreshNotice({ - tone: "warn", - text: "Token refresh failed. Using saved session token.", - }); - setStartupStatusMessage("Token refresh failed. Continuing with saved session..."); - } else if (sessionResult.refresh.outcome === "missing_refresh_token") { - setStartupStatusMessage("Saved session has no refresh token. Continuing..."); - } else if (persistedSession) { - setStartupStatusMessage("Session restored."); - } else { - setStartupStatusMessage("No saved session found."); - } - - // Load persisted variant selections from localStorage before applying defaults - try { - const raw = localStorage.getItem(VARIANT_SELECTION_LOCALSTORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object") { - setVariantByGameId(parsed as Record); - } - } - } catch (e) { - // ignore parse/storage errors - } - - // Update isInitializing FIRST so UI knows we're done loading - setIsInitializing(false); - setProviders(providerList); - setAuthSession(persistedSession); - - 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); - } - } else { - setGames([]); - setLibraryGames([]); - setSubscriptionInfo(null); - setCatalogFilterGroups([]); - setCatalogSortOptions([]); - setCatalogTotalCount(0); - setCatalogSupportedCount(0); - } - } catch (error) { - console.error("Initialization failed:", error); - setStartupStatusMessage("Session restore failed. Please sign in again."); - // Always set isInitializing to false even on error - setIsInitializing(false); - } - }; - - void initialize(); - }, []); - useEffect(() => { saveStoredCodecResults(codecResults); }, [codecResults]); @@ -2311,27 +2205,19 @@ 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); - - // Load regions - const token = session.tokens.idToken ?? session.tokens.accessToken; - const discovered = await window.openNow.getRegions({ token }); - setRegions(discovered); + const loadSessionRuntimeData = useCallback(async (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, @@ -2348,17 +2234,208 @@ 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); + } + }, [ + applyCatalogBrowseResult, + applyVariantSelections, + catalogSelectedFilterIds, + catalogSelectedSortId, + loadSubscriptionInfo, + ]); + + // Initialize app + useEffect(() => { + if (hasInitializedRef.current) return; + hasInitializedRef.current = true; + + const initialize = async () => { + try { + // Load settings first + const loadedSettings = await window.openNow.getSettings(); + setSettings(loadedSettings); + setShowStatsOverlay(loadedSettings.showStatsOnLaunch); + setSettingsLoaded(true); + + // Load providers and session (refresh only if token is near expiry) + setStartupStatusMessage("Restoring saved session..."); + const [providerList, sessionResult] = await Promise.all([ + window.openNow.getLoginProviders(), + window.openNow.getAuthSession(), + ]); + const accounts = await window.openNow.getSavedAccounts(); + const persistedSession = sessionResult.session; + + if (sessionResult.refresh.outcome === "refreshed") { + setStartupRefreshNotice({ + tone: "success", + text: "Session restored. Token refreshed.", + }); + setStartupStatusMessage("Token refreshed. Loading your account..."); + } else if (sessionResult.refresh.outcome === "failed") { + setStartupRefreshNotice({ + tone: "warn", + text: "Token refresh failed. Using saved session token.", + }); + setStartupStatusMessage("Token refresh failed. Continuing with saved session..."); + } else if (sessionResult.refresh.outcome === "missing_refresh_token") { + setStartupStatusMessage("Saved session has no refresh token. Continuing..."); + } else if (persistedSession) { + setStartupStatusMessage("Session restored."); + } else { + setStartupStatusMessage("No saved session found."); + } + + // Load persisted variant selections from localStorage before applying defaults + try { + const raw = localStorage.getItem(VARIANT_SELECTION_LOCALSTORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + setVariantByGameId(parsed as Record); + } + } + } catch (e) { + // ignore parse/storage errors + } + + setProviders(providerList); + setAuthSession(persistedSession); + setSavedAccounts(accounts); + + const activeProviderId = persistedSession?.provider?.idpId ?? providerList[0]?.idpId ?? ""; + setProviderIdpId(activeProviderId); + + if (persistedSession) { + await loadSessionRuntimeData(persistedSession); + } else { + setRegions([]); + setGames([]); + setLibraryGames([]); + setSubscriptionInfo(null); + setCatalogFilterGroups([]); + setCatalogSortOptions([]); + setCatalogTotalCount(0); + setCatalogSupportedCount(0); + } + + setIsInitializing(false); + } catch (error) { + console.error("Initialization failed:", error); + setStartupStatusMessage("Session restore failed. Please sign in again."); + // Always set isInitializing to false even on error + setIsInitializing(false); + } + }; + + void initialize(); + }, [loadSessionRuntimeData]); + + // 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"); + try { + await refreshSavedAccounts(); + const sessionResult = await window.openNow.getAuthSession(); + setAuthSession(sessionResult.session); + if (sessionResult.session) { + setProviderIdpId(sessionResult.session.provider.idpId); + await loadSessionRuntimeData(sessionResult.session); + await refreshNavbarActiveSession(sessionResult.session); + } else { + setRegions([]); + setGames([]); + setLibraryGames([]); + setSubscriptionInfo(null); + setNavbarActiveSession(null); + setCatalogFilterGroups([]); + setCatalogSortOptions([]); + setCatalogTotalCount(0); + setCatalogSupportedCount(0); + } + } catch (recoveryError) { + console.warn("Failed to recover account state after switch failure:", recoveryError); + } + } + }, [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); + await refreshNavbarActiveSession(sessionResult.session); + return; + } + setRegions([]); + setGames([]); + setLibraryGames([]); + setSubscriptionInfo(null); + setNavbarActiveSession(null); + setCatalogFilterGroups([]); + setCatalogSortOptions([]); + setCatalogTotalCount(0); + setCatalogSupportedCount(0); + }, [accountToRemove, loadSessionRuntimeData, refreshNavbarActiveSession]); + + const handleAddAccount = useCallback(() => { + setAuthSession(null); + setLoginError(null); + }, []); const confirmLogout = useCallback(async () => { setLogoutConfirmOpen(false); - await window.openNow.logout(); + await window.openNow.logoutAll(); setAuthSession(null); + setSavedAccounts([]); setGames([]); setLibraryGames([]); setVariantByGameId({}); @@ -3220,15 +3297,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(); + } } }; @@ -3240,7 +3326,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( @@ -3252,13 +3342,13 @@ export function App(): JSX.Element { aria-label="Cancel log out" />
-
Session
-

Log out of OpenNOW?

+
Accounts
+

Sign out all accounts?

- You're about to sign out of this device and return to guest mode. + You're about to remove every saved account from this device and return to guest mode.

- Your cloud session data stays on the service. This just clears your local app session. + Your cloud session data stays on the service. This just clears local OpenNOW account sessions.

+
+
+ Enter confirm ยท Esc cancel +
+
+ , + document.body, + ) + : null; + + const removeAccountConfirmModal = removeAccountConfirmOpen && typeof document !== "undefined" + ? createPortal( +
+ +
@@ -4037,7 +4178,13 @@ export function App(): JSX.Element { onTerminateSession={() => { void handleTerminateNavbarSession(); }} - onLogout={handleLogout} + savedAccounts={savedAccounts} + onSwitchAccount={handleSwitchAccount} + onRemoveAccount={(userId) => { + void handleRemoveAccount(userId); + }} + onAddAccount={handleAddAccount} + onLogoutAll={handleLogout} /> )} @@ -4154,6 +4301,7 @@ export function App(): JSX.Element { )} {logoutConfirmModal} + {removeAccountConfirmModal} {queueModalGame && streamStatus === "idle" && ( (null); const selectedProvider = providers.find((p) => p.idpId === selectedProviderId); + const title = isInitializing ? "Restoring session" : "Sign in"; + const subtitle = isInitializing ? "Checking saved accounts." : "Cloud gaming, open source."; useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -65,8 +67,8 @@ export function LoginScreen({ {/* Card */}
-

Sign in

-

Cloud gaming, open source.

+

{title}

+

{subtitle}

{error && ( diff --git a/opennow-stable/src/renderer/src/components/Navbar.tsx b/opennow-stable/src/renderer/src/components/Navbar.tsx index c220414f5..b773bbcbc 100644 --- a/opennow-stable/src/renderer/src/components/Navbar.tsx +++ b/opennow-stable/src/renderer/src/components/Navbar.tsx @@ -1,6 +1,6 @@ -import type { ActiveSessionInfo, AuthUser, SubscriptionInfo } from "@shared/gfn"; -import { House, Library, Settings, User, LogOut, Zap, Timer, HardDrive, X, Loader2, PlayCircle, Square } from "lucide-react"; -import { useEffect, useState, type JSX } from "react"; +import type { ActiveSessionInfo, AuthUser, SavedAccount, SubscriptionInfo } from "@shared/gfn"; +import { House, Library, Settings, User, Zap, Timer, HardDrive, X, Loader2, PlayCircle, Square, ChevronDown, Check, Plus } from "lucide-react"; +import { useEffect, useRef, useState, type JSX } from "react"; import { createPortal } from "react-dom"; interface NavbarProps { @@ -14,7 +14,11 @@ interface NavbarProps { isTerminatingSession: boolean; onResumeSession: () => void; onTerminateSession: () => void; - onLogout: () => void; + savedAccounts: SavedAccount[]; + onSwitchAccount: (userId: string) => void; + onRemoveAccount: (userId: string) => void; + onAddAccount: () => void; + onLogoutAll: () => void; } type NavbarModalType = "time" | "storage" | null; @@ -37,9 +41,15 @@ export function Navbar({ isTerminatingSession, onResumeSession, onTerminateSession, - onLogout, + savedAccounts, + onSwitchAccount, + onRemoveAccount, + onAddAccount, + onLogoutAll, }: 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 (!(event.target instanceof Node) || !accountContainerRef.current?.contains(event.target)) { + setAccountDropdownOpen(false); + } + }; + window.addEventListener("mousedown", onDocumentPointerDown); + return () => window.removeEventListener("mousedown", onDocumentPointerDown); + }, [accountDropdownOpen]); useEffect(() => { if (!modalType) return; @@ -339,24 +361,137 @@ export function Navbar({ )} {user ? ( <> -
- {user.avatarUrl ? ( - {user.displayName} - ) : ( -
- +
+ + {accountDropdownOpen && ( + - ) : (
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 57e7f0576..4d2ca3a9d 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; @@ -630,8 +656,11 @@ body, .navbar-user-info { display: flex; flex-direction: column; + align-items: flex-start; + min-width: 0; gap: 0; line-height: 1.2; + text-align: left; } .navbar-username { @@ -664,6 +693,212 @@ 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 { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 230px; + overflow-y: auto; + margin: 0; + padding: 0 8px 8px; + list-style: none; +} + +.navbar-account-item { + display: flex; + align-items: center; + min-height: 56px; + border-radius: var(--r-sm); + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.035); + overflow: hidden; + transition: background var(--t-fast), border-color var(--t-fast); +} + +.navbar-account-item--active { + border-color: color-mix(in srgb, var(--accent) 18%, transparent); + background: color-mix(in srgb, var(--accent) 14%, rgba(255, 255, 255, 0.035)); +} + +.navbar-account-item:not(.navbar-account-item--active):hover { + background: rgba(255, 255, 255, 0.06); +} + +.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: 9px 10px; + align-self: stretch; + cursor: pointer; + transition: color var(--t-fast); +} + +.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; + min-width: 52px; + justify-content: flex-end; +} + +.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 { + align-self: stretch; + width: 38px; + min-height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-left: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 0; + 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 a81477b0a..84e29b81e 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -266,6 +266,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; @@ -728,6 +737,10 @@ export interface OpenNowApi { getRegions(input?: RegionsFetchRequest): Promise; login(input: AuthLoginRequest): Promise; logout(): Promise; + logoutAll(): 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 117eecf12..5edc4740b 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -4,6 +4,10 @@ export const IPC_CHANNELS = { AUTH_GET_REGIONS: "auth:get-regions", AUTH_LOGIN: "auth:login", AUTH_LOGOUT: "auth:logout", + AUTH_LOGOUT_ALL: "auth:logout-all", + 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",