From 30ce65e7474d856866d6d24186400186cb94c973 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 28 Jan 2026 02:39:44 -0700 Subject: [PATCH] refactor(auth): bootstrap anonymous sessions via AuthLoader Replace privacy popup with PrivacyModal and reorganize auth UI components Remove signed-out persistence and centralize session handling in useAuthSession Move document viewers and Kokoro voice helpers into dedicated modules --- src/app/api/tts/route.ts | 2 +- src/app/api/tts/voices/route.ts | 4 +- src/app/epub/[id]/page.tsx | 4 +- src/app/html/[id]/page.tsx | 4 +- src/app/layout.tsx | 2 +- src/app/page.tsx | 19 ++---- src/app/pdf/[id]/page.tsx | 4 +- src/app/providers.tsx | 35 +++++----- src/app/signin/page.tsx | 64 +++++++----------- src/app/signup/page.tsx | 4 +- src/components/Footer.tsx | 4 +- .../{privacy-popup.tsx => PrivacyModal.tsx} | 14 ++-- src/components/SettingsModal.tsx | 30 ++++----- src/components/auth/AuthLoader.tsx | 67 +++++++++++++------ src/components/{ => auth}/ClaimDataModal.tsx | 2 +- .../RateLimitBanner.tsx} | 0 src/components/auth/UserMenu.tsx | 22 +++--- src/components/player/VoicesControlBase.tsx | 3 +- src/components/{ => views}/EPUBViewer.tsx | 0 src/components/{ => views}/HTMLViewer.tsx | 0 src/components/{ => views}/PDFViewer.tsx | 0 src/contexts/TTSContext.tsx | 2 +- src/hooks/{useAuth.ts => useAuthSession.ts} | 0 src/{utils/voice.ts => lib/kokoro.ts} | 7 +- src/lib/session-utils.ts | 26 ------- src/types/config.ts | 2 - tests/helpers.ts | 2 +- 27 files changed, 147 insertions(+), 176 deletions(-) rename src/components/{privacy-popup.tsx => PrivacyModal.tsx} (96%) rename src/components/{ => auth}/ClaimDataModal.tsx (98%) rename src/components/{rate-limit-banner.tsx => auth/RateLimitBanner.tsx} (100%) rename src/components/{ => views}/EPUBViewer.tsx (100%) rename src/components/{ => views}/HTMLViewer.tsx (100%) rename src/components/{ => views}/PDFViewer.tsx (100%) rename src/hooks/{useAuth.ts => useAuthSession.ts} (100%) rename src/{utils/voice.ts => lib/kokoro.ts} (92%) delete mode 100644 src/lib/session-utils.ts diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index d71ea94..ad64150 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import OpenAI from 'openai'; import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs'; -import { isKokoroModel } from '@/utils/voice'; +import { isKokoroModel } from '@/lib/kokoro'; import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; import type { TTSRequestPayload } from '@/types/client'; diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index bd96e0d..2fc45c8 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { isKokoroModel } from '@/utils/voice'; +import { isKokoroModel } from '@/lib/kokoro'; import { auth } from '@/lib/server/auth'; const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']; @@ -161,4 +161,4 @@ export async function GET(req: NextRequest) { const model = req.headers.get('x-tts-model') || 'tts-1'; return NextResponse.json({ voices: getDefaultVoices(provider, model) }); } -} \ No newline at end of file +} diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index a65f21c..7744c67 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { useEPUB } from '@/contexts/EPUBContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; -import { EPUBViewer } from '@/components/EPUBViewer'; +import { EPUBViewer } from '@/components/views/EPUBViewer'; import { DocumentSettings } from '@/components/DocumentSettings'; import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; @@ -16,7 +16,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { resolveDocumentId } from '@/lib/dexie'; -import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index 53617a9..f70381b 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { useHTML } from '@/contexts/HTMLContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; -import { HTMLViewer } from '@/components/HTMLViewer'; +import { HTMLViewer } from '@/components/views/HTMLViewer'; import { DocumentSettings } from '@/components/DocumentSettings'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { Header } from '@/components/Header'; @@ -13,7 +13,7 @@ import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; import { resolveDocumentId } from '@/lib/dexie'; import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu'; -import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; export default function HTMLPage() { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5df0705..0fc698b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,7 @@ import { Footer } from "@/components/Footer"; import { Toaster } from 'react-hot-toast'; import { Analytics } from "@vercel/analytics/next"; import { isAuthEnabled, getAuthBaseUrl } from '@/lib/server/auth-config'; -import ClaimDataPopup from '@/components/ClaimDataModal'; +import ClaimDataPopup from '@/components/auth/ClaimDataModal'; export const metadata: Metadata = { title: "OpenReader WebUI", diff --git a/src/app/page.tsx b/src/app/page.tsx index 9ddf0bc..402c51e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,7 @@ import { Header } from '@/components/Header'; import { HomeContent } from '@/components/HomeContent'; import { SettingsModal } from '@/components/SettingsModal'; import { UserMenu } from '@/components/auth/UserMenu'; -import { AuthLoader } from '@/components/auth/AuthLoader'; -import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; export default function Home() { return ( @@ -23,16 +22,12 @@ export default function Home() { } /> - - <> -
-
- - -
-
- -
+
+
+ + +
+
); } diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 9debab7..f2f8ed5 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -16,14 +16,14 @@ import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { resolveDocumentId } from '@/lib/dexie'; -import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; // Dynamic import for client-side rendering only const PDFViewer = dynamic( - () => import('@/components/PDFViewer').then((module) => module.PDFViewer), + () => import('@/components/views/PDFViewer').then((module) => module.PDFViewer), { ssr: false, loading: () => diff --git a/src/app/providers.tsx b/src/app/providers.tsx index f21aaf5..591346b 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -8,7 +8,8 @@ import { ThemeProvider } from '@/contexts/ThemeContext'; import { ConfigProvider } from '@/contexts/ConfigContext'; import { HTMLProvider } from '@/contexts/HTMLContext'; import { AuthRateLimitProvider } from '@/contexts/AuthRateLimitContext'; -import { PrivacyPopup } from '@/components/privacy-popup'; +import { PrivacyModal } from '@/components/PrivacyModal'; +import { AuthLoader } from '@/components/auth/AuthLoader'; interface ProvidersProps { children: ReactNode; @@ -20,20 +21,24 @@ export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps return ( - - - - - - - {children} - - - - - - - + + + + + + + + <> + {children} + + + + + + + + + ); diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx index 5794643..5348213 100644 --- a/src/app/signin/page.tsx +++ b/src/app/signin/page.tsx @@ -6,8 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/auth-client'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; -import { showPrivacyPopup } from '@/components/privacy-popup'; -import { wasSignedOut, clearSignedOut } from '@/lib/session-utils'; +import { showPrivacyModal } from '@/components/PrivacyModal'; import { GithubIcon } from '@/components/icons/Icons'; import { LoadingSpinner } from '@/components/Spinner'; @@ -26,15 +25,14 @@ function SignInContent() { const [password, setPassword] = useState(''); const [loadingEmail, setLoadingEmail] = useState(false); const [loadingGithub, setLoadingGithub] = useState(false); - const [loadingGuest, setLoadingGuest] = useState(false); + const [loadingAnonymous, setLoadingAnonymous] = useState(false); const [rememberMe, setRememberMe] = useState(true); const [sessionExpired, setSessionExpired] = useState(false); - const [justSignedOut, setJustSignedOut] = useState(false); const [error, setError] = useState(null); const { authEnabled, baseUrl } = useAuthConfig(); const { refresh: refreshRateLimit } = useAuthRateLimit(); - const isAnyLoading = loadingEmail || loadingGithub || loadingGuest; + const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous; // Check if auth is enabled, redirect home if not useEffect(() => { @@ -43,16 +41,6 @@ function SignInContent() { } }, [router, authEnabled]); - // Detect explicit sign-out - useEffect(() => { - wasSignedOut().then(signedOut => { - if (signedOut) { - setJustSignedOut(true); - clearSignedOut(); - } - }); - }, []); - const validateEmail = (email: string): boolean => { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }; @@ -114,8 +102,8 @@ function SignInContent() { } }; - const handleGuestSignIn = async () => { - setLoadingGuest(true); + const handleAnonymousContinue = async () => { + setLoadingAnonymous(true); setError(null); try { const client = getAuthClient(baseUrl); @@ -124,9 +112,9 @@ function SignInContent() { router.push('/'); } catch (e) { console.error('Anonymous sign-in failed:', e); - setError('Unable to continue as guest. Please try again.'); + setError('Unable to continue anonymously. Please try again.'); } finally { - setLoadingGuest(false); + setLoadingAnonymous(false); } }; @@ -140,25 +128,21 @@ function SignInContent() { -
-

- {sessionExpired ? 'Session Expired' : 'Sign In'} -

-

- {sessionExpired - ? 'Please sign in again to continue' - : justSignedOut - ? 'Sign in to continue' - : 'Enter your email below to login'} -

+
+

+ {sessionExpired ? 'Session Expired' : 'Connect Account'} +

+

+ {sessionExpired + ? 'Please sign in again to continue' + : 'Connect an email account to sync your data across devices'} +

{/* Alerts */} - {(sessionExpired || justSignedOut) && ( + {sessionExpired && (

- {sessionExpired - ? 'Your session has expired. Please sign in again.' - : "You've been signed out."} + Your session has expired. Please sign in again.

)} @@ -207,7 +191,7 @@ function SignInContent() { Remember me - {/* Sign In Button */} + {/* Connect Button */} {/* GitHub */} @@ -241,17 +225,17 @@ function SignInContent() { )} - {/* Guest */} + {/* Anonymous */}
@@ -266,7 +250,7 @@ function SignInContent() {

By signing in, you agree to our{' '}

@@ -847,7 +843,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]" > - Sign Out + Disconnect account
@@ -874,12 +870,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 24d5137..3aca979 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useEffect, useState, ReactNode } from 'react'; +import { useEffect, useRef, useState, ReactNode } from 'react'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; -import { useAuthSession } from '@/hooks/useAuth'; +import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/auth-client'; import { LoadingSpinner } from '@/components/Spinner'; @@ -11,29 +11,35 @@ export function AuthLoader({ children }: { children: ReactNode }) { const { refresh: refreshRateLimit } = useAuthRateLimit(); const { data: session, isPending } = useAuthSession(); const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); - const [isCheckingSignOut, setIsCheckingSignOut] = useState(true); + const [bootstrapError, setBootstrapError] = useState(null); + const [retryNonce, setRetryNonce] = useState(0); + const attemptedForNullSessionRef = useRef(false); + + // If the auth base URL changes, re-run the bootstrap logic. + useEffect(() => { + attemptedForNullSessionRef.current = false; + setBootstrapError(null); + }, [authEnabled, baseUrl]); useEffect(() => { - // Determine if we need to check sign-out status or proceed + // This app does not have a real "signed out" state when auth is enabled. + // If we ever observe "no session", we immediately start an anonymous session. const checkStatus = async () => { - // If auth is disabled, stop checking immediately - if (!authEnabled) { - setIsCheckingSignOut(false); - return; - } - - // If session is still loading, wait + if (!authEnabled) return; if (isPending) return; - // If we have a session, we are done checking if (session) { - setIsCheckingSignOut(false); + attemptedForNullSessionRef.current = false; + setBootstrapError(null); return; } - // Not signed out, start auto-login + // Avoid double-calling anonymous sign-in (e.g. React strict mode). + if (attemptedForNullSessionRef.current) return; + attemptedForNullSessionRef.current = true; + setIsAutoLoggingIn(true); - setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode + setBootstrapError(null); try { const client = getAuthClient(baseUrl); @@ -41,27 +47,44 @@ export function AuthLoader({ children }: { children: ReactNode }) { await refreshRateLimit(); } catch (err) { console.error('Auto-login failed', err); + setBootstrapError('Unable to start an anonymous session.'); } finally { setIsAutoLoggingIn(false); } }; checkStatus(); - }, [session, isPending, authEnabled, baseUrl, refreshRateLimit]); + }, [session, isPending, authEnabled, baseUrl, refreshRateLimit, retryNonce]); // Show loader if: // 1. Auth client is initializing (isPending) AND auth is enabled - // 2. We are checking the sign-out status (Dexie) - // 3. We are actively auto-logging in - const isLoading = (isPending && authEnabled) || isCheckingSignOut || isAutoLoggingIn; + // 2. We are actively creating an anonymous session + const isLoading = authEnabled && (isPending || isAutoLoggingIn || !session); if (isLoading) { return (
-

- {isAutoLoggingIn ? 'Logging in anonymously...' : 'Loading...'} -

+ {bootstrapError ? ( +
+

{bootstrapError}

+ +
+ ) : ( +

+ {isAutoLoggingIn ? 'Starting anonymous session...' : 'Loading...'} +

+ )}
); } diff --git a/src/components/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx similarity index 98% rename from src/components/ClaimDataModal.tsx rename to src/components/auth/ClaimDataModal.tsx index fb42cf2..0539ca6 100644 --- a/src/components/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -9,7 +9,7 @@ import { TransitionChild, Button, } from '@headlessui/react'; -import { useAuthSession } from '@/hooks/useAuth'; +import { useAuthSession } from '@/hooks/useAuthSession'; import { useRouter } from 'next/navigation'; export default function ClaimDataModal() { diff --git a/src/components/rate-limit-banner.tsx b/src/components/auth/RateLimitBanner.tsx similarity index 100% rename from src/components/rate-limit-banner.tsx rename to src/components/auth/RateLimitBanner.tsx diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index d6de035..f37fdaa 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -2,26 +2,24 @@ import { Button } from '@headlessui/react'; import Link from 'next/link'; -import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; -import { useAuthSession } from '@/hooks/useAuth'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; +import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/auth-client'; -import { clearSignedOut } from '@/lib/session-utils'; import { useRouter } from 'next/navigation'; export function UserMenu({ className = '' }: { className?: string }) { const { authEnabled, baseUrl } = useAuthConfig(); - const { refresh: refreshRateLimit } = useAuthRateLimit(); const { data: session, isPending } = useAuthSession(); const router = useRouter(); if (!authEnabled || isPending) return null; - const handleSignOut = async () => { + const handleDisconnectAccount = async () => { const client = getAuthClient(baseUrl); + // "Sign out" here means: end the email/social session and immediately + // start a fresh anonymous session. The app should never be left without a session. await client.signOut(); - await clearSignedOut(); - await client.signIn.anonymous(); - await refreshRateLimit(); + // AuthLoader will create the next anonymous session. router.refresh(); }; @@ -30,12 +28,12 @@ export function UserMenu({ className = '' }: { className?: string }) {
@@ -49,9 +47,9 @@ export function UserMenu({ className = '' }: { className?: string }) {
); } - diff --git a/src/components/EPUBViewer.tsx b/src/components/views/EPUBViewer.tsx similarity index 100% rename from src/components/EPUBViewer.tsx rename to src/components/views/EPUBViewer.tsx diff --git a/src/components/HTMLViewer.tsx b/src/components/views/HTMLViewer.tsx similarity index 100% rename from src/components/HTMLViewer.tsx rename to src/components/views/HTMLViewer.tsx diff --git a/src/components/PDFViewer.tsx b/src/components/views/PDFViewer.tsx similarity index 100% rename from src/components/PDFViewer.tsx rename to src/components/views/PDFViewer.tsx diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 2006e4e..fa42b22 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -38,7 +38,7 @@ import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry, generateTTS, alignAudio } from '@/lib/client'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; -import { isKokoroModel } from '@/utils/voice'; +import { isKokoroModel } from '@/lib/kokoro'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import type { TTSLocation, diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuthSession.ts similarity index 100% rename from src/hooks/useAuth.ts rename to src/hooks/useAuthSession.ts diff --git a/src/utils/voice.ts b/src/lib/kokoro.ts similarity index 92% rename from src/utils/voice.ts rename to src/lib/kokoro.ts index ba1852d..e8f419c 100644 --- a/src/utils/voice.ts +++ b/src/lib/kokoro.ts @@ -1,8 +1,7 @@ /** - * Voice Utilities - * - * This module provides utilities for handling voice selection and management, - * particularly for Kokoro multi-voice syntax. + * Kokoro Utilities + * + * Utilities for handling Kokoro multi-voice syntax. */ /** diff --git a/src/lib/session-utils.ts b/src/lib/session-utils.ts deleted file mode 100644 index 4536fae..0000000 --- a/src/lib/session-utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Session utilities using Dexie for persistence -// Used by auth components for sign-out state tracking - -import { updateAppConfig, getAppConfig } from '@/lib/dexie'; - -/** - * Check if user was explicitly signed out (for showing appropriate message on signin page) - */ -export async function wasSignedOut(): Promise { - const config = await getAppConfig(); - return config?.signedOut ?? false; -} - -/** - * Clear the signed-out flag after displaying the message - */ -export async function clearSignedOut(): Promise { - await updateAppConfig({ signedOut: false }); -} - -/** - * Mark that the user explicitly signed out - */ -export async function markSignedOut(): Promise { - await updateAppConfig({ signedOut: true }); -} diff --git a/src/types/config.ts b/src/types/config.ts index 6a39db3..2491302 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -30,7 +30,6 @@ export interface AppConfigValues { epubWordHighlightEnabled: boolean; firstVisit: boolean; documentListState: DocumentListState; - signedOut: boolean; privacyAccepted: boolean; } @@ -65,7 +64,6 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = { showHint: true, viewMode: 'grid', }, - signedOut: false, privacyAccepted: false, }; diff --git a/tests/helpers.ts b/tests/helpers.ts index c29b54c..3121469 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -166,7 +166,7 @@ export async function setupTest(page: Page) { // If auth is enabled, establish an anonymous session BEFORE navigation. // This keeps each test self-contained (no shared storageState) while ensuring // server routes that require auth don't intermittently 401 during app startup. - await ensureAnonymousSession(page); + // await ensureAnonymousSession(page); // Navigate to the home page before each test await page.goto('/');