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
This commit is contained in:
Richard R 2026-01-28 02:39:44 -07:00
parent 20111b1e36
commit 30ce65e747
27 changed files with 147 additions and 176 deletions

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import OpenAI from 'openai'; import OpenAI from 'openai';
import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs'; import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
import { isKokoroModel } from '@/utils/voice'; import { isKokoroModel } from '@/lib/kokoro';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import type { TTSRequestPayload } from '@/types/client'; import type { TTSRequestPayload } from '@/types/client';

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { isKokoroModel } from '@/utils/voice'; import { isKokoroModel } from '@/lib/kokoro';
import { auth } from '@/lib/server/auth'; import { auth } from '@/lib/server/auth';
const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']; 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'; const model = req.headers.get('x-tts-model') || 'tts-1';
return NextResponse.json({ voices: getDefaultVoices(provider, model) }); return NextResponse.json({ voices: getDefaultVoices(provider, model) });
} }
} }

View file

@ -5,7 +5,7 @@ import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useEPUB } from '@/contexts/EPUBContext'; import { useEPUB } from '@/contexts/EPUBContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { EPUBViewer } from '@/components/EPUBViewer'; import { EPUBViewer } from '@/components/views/EPUBViewer';
import { DocumentSettings } from '@/components/DocumentSettings'; import { DocumentSettings } from '@/components/DocumentSettings';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext"; import { useTTS } from "@/contexts/TTSContext";
@ -16,7 +16,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import type { TTSAudiobookChapter } from '@/types/tts'; import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import { resolveDocumentId } from '@/lib/dexie'; import { resolveDocumentId } from '@/lib/dexie';
import { RateLimitBanner } from '@/components/rate-limit-banner'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;

View file

@ -5,7 +5,7 @@ import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useHTML } from '@/contexts/HTMLContext'; import { useHTML } from '@/contexts/HTMLContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { HTMLViewer } from '@/components/HTMLViewer'; import { HTMLViewer } from '@/components/views/HTMLViewer';
import { DocumentSettings } from '@/components/DocumentSettings'; import { DocumentSettings } from '@/components/DocumentSettings';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
@ -13,7 +13,7 @@ import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer'; import TTSPlayer from '@/components/player/TTSPlayer';
import { resolveDocumentId } from '@/lib/dexie'; import { resolveDocumentId } from '@/lib/dexie';
import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu'; import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu';
import { RateLimitBanner } from '@/components/rate-limit-banner'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
export default function HTMLPage() { export default function HTMLPage() {

View file

@ -6,7 +6,7 @@ import { Footer } from "@/components/Footer";
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { Analytics } from "@vercel/analytics/next"; import { Analytics } from "@vercel/analytics/next";
import { isAuthEnabled, getAuthBaseUrl } from '@/lib/server/auth-config'; import { isAuthEnabled, getAuthBaseUrl } from '@/lib/server/auth-config';
import ClaimDataPopup from '@/components/ClaimDataModal'; import ClaimDataPopup from '@/components/auth/ClaimDataModal';
export const metadata: Metadata = { export const metadata: Metadata = {
title: "OpenReader WebUI", title: "OpenReader WebUI",

View file

@ -2,8 +2,7 @@ import { Header } from '@/components/Header';
import { HomeContent } from '@/components/HomeContent'; import { HomeContent } from '@/components/HomeContent';
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from '@/components/SettingsModal';
import { UserMenu } from '@/components/auth/UserMenu'; import { UserMenu } from '@/components/auth/UserMenu';
import { AuthLoader } from '@/components/auth/AuthLoader'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { RateLimitBanner } from '@/components/rate-limit-banner';
export default function Home() { export default function Home() {
return ( return (
@ -23,16 +22,12 @@ export default function Home() {
</div> </div>
} }
/> />
<AuthLoader> <section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
<> <div className="max-w-7xl mx-auto">
<section className="flex-1 px-4 pb-8 pt-4 overflow-auto"> <RateLimitBanner className="mb-6" />
<div className="max-w-7xl mx-auto"> <HomeContent />
<RateLimitBanner className="mb-6" /> </div>
<HomeContent /> </section>
</div>
</section>
</>
</AuthLoader>
</div> </div>
); );
} }

View file

@ -16,14 +16,14 @@ import type { AudiobookGenerationSettings } from '@/types/client';
import TTSPlayer from '@/components/player/TTSPlayer'; import TTSPlayer from '@/components/player/TTSPlayer';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { resolveDocumentId } from '@/lib/dexie'; import { resolveDocumentId } from '@/lib/dexie';
import { RateLimitBanner } from '@/components/rate-limit-banner'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
// Dynamic import for client-side rendering only // Dynamic import for client-side rendering only
const PDFViewer = dynamic( const PDFViewer = dynamic(
() => import('@/components/PDFViewer').then((module) => module.PDFViewer), () => import('@/components/views/PDFViewer').then((module) => module.PDFViewer),
{ {
ssr: false, ssr: false,
loading: () => <DocumentSkeleton /> loading: () => <DocumentSkeleton />

View file

@ -8,7 +8,8 @@ import { ThemeProvider } from '@/contexts/ThemeContext';
import { ConfigProvider } from '@/contexts/ConfigContext'; import { ConfigProvider } from '@/contexts/ConfigContext';
import { HTMLProvider } from '@/contexts/HTMLContext'; import { HTMLProvider } from '@/contexts/HTMLContext';
import { AuthRateLimitProvider } from '@/contexts/AuthRateLimitContext'; import { AuthRateLimitProvider } from '@/contexts/AuthRateLimitContext';
import { PrivacyPopup } from '@/components/privacy-popup'; import { PrivacyModal } from '@/components/PrivacyModal';
import { AuthLoader } from '@/components/auth/AuthLoader';
interface ProvidersProps { interface ProvidersProps {
children: ReactNode; children: ReactNode;
@ -20,20 +21,24 @@ export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps
return ( return (
<AuthRateLimitProvider authEnabled={authEnabled} authBaseUrl={authBaseUrl}> <AuthRateLimitProvider authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
<ThemeProvider> <ThemeProvider>
<ConfigProvider> <AuthLoader>
<DocumentProvider> <ConfigProvider>
<TTSProvider> <DocumentProvider>
<PDFProvider> <TTSProvider>
<EPUBProvider> <PDFProvider>
<HTMLProvider> <EPUBProvider>
{children} <HTMLProvider>
<PrivacyPopup authEnabled={authEnabled} /> <>
</HTMLProvider> {children}
</EPUBProvider> <PrivacyModal authEnabled={authEnabled} />
</PDFProvider> </>
</TTSProvider> </HTMLProvider>
</DocumentProvider> </EPUBProvider>
</ConfigProvider> </PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
</AuthLoader>
</ThemeProvider> </ThemeProvider>
</AuthRateLimitProvider> </AuthRateLimitProvider>
); );

View file

@ -6,8 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { showPrivacyPopup } from '@/components/privacy-popup'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { wasSignedOut, clearSignedOut } from '@/lib/session-utils';
import { GithubIcon } from '@/components/icons/Icons'; import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
@ -26,15 +25,14 @@ function SignInContent() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loadingEmail, setLoadingEmail] = useState(false); const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGithub, setLoadingGithub] = useState(false); const [loadingGithub, setLoadingGithub] = useState(false);
const [loadingGuest, setLoadingGuest] = useState(false); const [loadingAnonymous, setLoadingAnonymous] = useState(false);
const [rememberMe, setRememberMe] = useState(true); const [rememberMe, setRememberMe] = useState(true);
const [sessionExpired, setSessionExpired] = useState(false); const [sessionExpired, setSessionExpired] = useState(false);
const [justSignedOut, setJustSignedOut] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { authEnabled, baseUrl } = useAuthConfig(); const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAuthRateLimit(); const { refresh: refreshRateLimit } = useAuthRateLimit();
const isAnyLoading = loadingEmail || loadingGithub || loadingGuest; const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous;
// Check if auth is enabled, redirect home if not // Check if auth is enabled, redirect home if not
useEffect(() => { useEffect(() => {
@ -43,16 +41,6 @@ function SignInContent() {
} }
}, [router, authEnabled]); }, [router, authEnabled]);
// Detect explicit sign-out
useEffect(() => {
wasSignedOut().then(signedOut => {
if (signedOut) {
setJustSignedOut(true);
clearSignedOut();
}
});
}, []);
const validateEmail = (email: string): boolean => { const validateEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}; };
@ -114,8 +102,8 @@ function SignInContent() {
} }
}; };
const handleGuestSignIn = async () => { const handleAnonymousContinue = async () => {
setLoadingGuest(true); setLoadingAnonymous(true);
setError(null); setError(null);
try { try {
const client = getAuthClient(baseUrl); const client = getAuthClient(baseUrl);
@ -124,9 +112,9 @@ function SignInContent() {
router.push('/'); router.push('/');
} catch (e) { } catch (e) {
console.error('Anonymous sign-in failed:', 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 { } finally {
setLoadingGuest(false); setLoadingAnonymous(false);
} }
}; };
@ -140,25 +128,21 @@ function SignInContent() {
<SessionExpiredLoader setSessionExpired={setSessionExpired} /> <SessionExpiredLoader setSessionExpired={setSessionExpired} />
</Suspense> </Suspense>
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6"> <div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
<h1 className="text-xl font-semibold text-foreground"> <h1 className="text-xl font-semibold text-foreground">
{sessionExpired ? 'Session Expired' : 'Sign In'} {sessionExpired ? 'Session Expired' : 'Connect Account'}
</h1> </h1>
<p className="text-sm text-muted mt-1"> <p className="text-sm text-muted mt-1">
{sessionExpired {sessionExpired
? 'Please sign in again to continue' ? 'Please sign in again to continue'
: justSignedOut : 'Connect an email account to sync your data across devices'}
? 'Sign in to continue' </p>
: 'Enter your email below to login'}
</p>
{/* Alerts */} {/* Alerts */}
{(sessionExpired || justSignedOut) && ( {sessionExpired && (
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg"> <div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<p className="text-sm text-amber-700 dark:text-amber-400"> <p className="text-sm text-amber-700 dark:text-amber-400">
{sessionExpired Your session has expired. Please sign in again.
? 'Your session has expired. Please sign in again.'
: "You've been signed out."}
</p> </p>
</div> </div>
)} )}
@ -207,7 +191,7 @@ function SignInContent() {
<span className="text-sm text-foreground">Remember me</span> <span className="text-sm text-foreground">Remember me</span>
</label> </label>
{/* Sign In Button */} {/* Connect Button */}
<Button <Button
type="submit" type="submit"
disabled={isAnyLoading} disabled={isAnyLoading}
@ -217,7 +201,7 @@ function SignInContent() {
focus:ring-offset-2 disabled:opacity-50 transform transition-transform focus:ring-offset-2 disabled:opacity-50 transform transition-transform
duration-200 hover:scale-[1.02]" duration-200 hover:scale-[1.02]"
> >
{loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Sign In'} {loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'}
</Button> </Button>
{/* GitHub */} {/* GitHub */}
@ -241,17 +225,17 @@ function SignInContent() {
)} )}
</Button> </Button>
{/* Guest */} {/* Anonymous */}
<Button <Button
type="button" type="button"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleGuestSignIn} onClick={handleAnonymousContinue}
className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground className="w-full rounded-lg bg-background py-2 text-sm font-medium text-foreground
hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent
focus:ring-offset-2 disabled:opacity-50 border border-offbase focus:ring-offset-2 disabled:opacity-50 border border-offbase
transform transition-transform duration-200 hover:scale-[1.02]" transform transition-transform duration-200 hover:scale-[1.02]"
> >
{loadingGuest ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue as Guest'} {loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
</Button> </Button>
</div> </div>
@ -266,7 +250,7 @@ function SignInContent() {
<p className="text-xs text-muted"> <p className="text-xs text-muted">
By signing in, you agree to our{' '} By signing in, you agree to our{' '}
<button <button
onClick={() => showPrivacyPopup({ authEnabled })} onClick={() => showPrivacyModal({ authEnabled })}
className="underline hover:text-foreground" className="underline hover:text-foreground"
> >
Privacy Policy Privacy Policy

View file

@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { showPrivacyPopup } from '@/components/privacy-popup'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -229,7 +229,7 @@ export default function SignUpPage() {
<p className="text-xs text-muted"> <p className="text-xs text-muted">
By creating an account, you agree to our{' '} By creating an account, you agree to our{' '}
<button <button
onClick={() => showPrivacyPopup({ authEnabled })} onClick={() => showPrivacyModal({ authEnabled })}
className="underline hover:text-foreground" className="underline hover:text-foreground"
> >
Privacy Policy Privacy Policy

View file

@ -1,7 +1,7 @@
'use client' 'use client'
import { GithubIcon } from '@/components/icons/Icons' import { GithubIcon } from '@/components/icons/Icons'
import { showPrivacyPopup } from '@/components/privacy-popup' import { showPrivacyModal } from '@/components/PrivacyModal'
import { useAuthConfig } from '@/contexts/AuthRateLimitContext' import { useAuthConfig } from '@/contexts/AuthRateLimitContext'
export function Footer() { export function Footer() {
@ -23,7 +23,7 @@ export function Footer() {
<span className='w-full sm:w-fit'></span> <span className='w-full sm:w-fit'></span>
<button <button
type="button" type="button"
onClick={() => showPrivacyPopup({ authEnabled })} onClick={() => showPrivacyModal({ authEnabled })}
className="font-bold hover:text-foreground transition-colors outline-none" className="font-bold hover:text-foreground transition-colors outline-none"
> >
Privacy Privacy

View file

@ -13,12 +13,12 @@ import { updateAppConfig, getAppConfig } from '@/lib/dexie';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
interface PrivacyPopupProps { interface PrivacyModalProps {
onAccept?: () => void; onAccept?: () => void;
authEnabled?: boolean; authEnabled?: boolean;
} }
function PrivacyPopupBody({ function PrivacyModalBody({
origin, origin,
authEnabled, authEnabled,
}: { }: {
@ -125,7 +125,7 @@ function PrivacyPopupBody({
); );
} }
export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProps) { export function PrivacyModal({ onAccept, authEnabled = false }: PrivacyModalProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [origin, setOrigin] = useState(''); const [origin, setOrigin] = useState('');
@ -188,7 +188,7 @@ export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProp
Privacy & Data Usage Privacy & Data Usage
</DialogTitle> </DialogTitle>
<PrivacyPopupBody origin={origin} authEnabled={authEnabled} /> <PrivacyModalBody origin={origin} authEnabled={authEnabled} />
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button <Button
@ -215,10 +215,10 @@ export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProp
* Function to programmatically show the privacy popup * Function to programmatically show the privacy popup
* This can be called from signin/signup components * This can be called from signin/signup components
*/ */
export function showPrivacyPopup(options?: { authEnabled?: boolean }): void { export function showPrivacyModal(options?: { authEnabled?: boolean }): void {
// Create a temporary container for the popup // Create a temporary container for the popup
const container = document.createElement('div'); const container = document.createElement('div');
container.id = 'privacy-popup-container'; container.id = 'privacy-modal-container';
document.body.appendChild(container); document.body.appendChild(container);
const origin = typeof window !== 'undefined' ? window.location.origin : ''; const origin = typeof window !== 'undefined' ? window.location.origin : '';
@ -278,7 +278,7 @@ export function showPrivacyPopup(options?: { authEnabled?: boolean }): void {
Privacy & Data Usage Privacy & Data Usage
</DialogTitle> </DialogTitle>
<PrivacyPopupBody origin={origin} authEnabled={authEnabled} /> <PrivacyModalBody origin={origin} authEnabled={authEnabled} />
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button <Button

View file

@ -33,11 +33,10 @@ import { deleteServerDocuments } from '@/lib/client';
import { DocumentSelectionModal } from '@/components/DocumentSelectionModal'; import { DocumentSelectionModal } from '@/components/DocumentSelectionModal';
import { BaseDocument } from '@/types/documents'; import { BaseDocument } from '@/types/documents';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { useAuthSession } from '@/hooks/useAuth'; import { useAuthSession } from '@/hooks/useAuthSession';
import { clearSignedOut } from '@/lib/session-utils'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { showPrivacyPopup } from '@/components/privacy-popup'; import { showPrivacyModal } from '@/components/PrivacyModal';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -86,7 +85,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAuthRateLimit();
const { data: session } = useAuthSession(); const { data: session } = useAuthSession();
const router = useRouter(); const router = useRouter();
@ -335,12 +333,10 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const handleSignOut = async () => { const handleSignOut = async () => {
const client = getAuthClient(authBaseUrl); const client = getAuthClient(authBaseUrl);
// Sign out of the authenticated account, then immediately start a fresh anonymous session. // "Sign out" here means: disconnect the email/social account and return to a fresh
// This avoids landing in a "no session" state that can be misinterpreted as a "Guest" user. // anonymous session. The app should not be able to end up truly signed out.
await client.signOut(); await client.signOut();
await clearSignedOut(); // AuthLoader will create the next anonymous session and refresh rate limit state.
await client.signIn.anonymous();
await refreshRateLimit();
router.refresh(); router.refresh();
}; };
@ -352,8 +348,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
// Sign out locally // Sign out locally
const client = getAuthClient(authBaseUrl); const client = getAuthClient(authBaseUrl);
await client.signOut(); await client.signOut();
// Clear the "signed out" flag so AuthLoader triggers auto-anon-login // After account deletion, we return to a fresh anonymous session.
clearSignedOut(); // AuthLoader will create the session if one isn't present yet.
window.location.href = '/'; window.location.href = '/';
} catch (error) { } catch (error) {
console.error('Failed to delete account:', error); console.error('Failed to delete account:', error);
@ -436,7 +432,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</DialogTitle> </DialogTitle>
<Button <Button
onClick={() => showPrivacyPopup({ authEnabled })} onClick={() => showPrivacyModal({ authEnabled })}
className="text-sm font-medium text-muted hover:text-accent" className="text-sm font-medium text-muted hover:text-accent"
> >
Privacy Privacy
@ -832,7 +828,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
)} )}
</> </>
) : ( ) : (
<p className="font-medium text-foreground">Not signed in</p> <p className="font-medium text-foreground">No active session</p>
)} )}
</div> </div>
</div> </div>
@ -847,7 +843,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 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]" transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
> >
Sign Out Disconnect account
</Button> </Button>
<div className="pt-4 border-t border-offbase"> <div className="pt-4 border-t border-offbase">
@ -874,12 +870,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Link href="/signin" className="w-full"> <Link href="/signin" className="w-full">
<Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> <Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">
Log In Connect
</Button> </Button>
</Link> </Link>
<Link href="/signup" className="w-full"> <Link href="/signup" className="w-full">
<Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> <Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">
Sign Up Create account
</Button> </Button>
</Link> </Link>
</div> </div>

View file

@ -1,8 +1,8 @@
'use client'; 'use client';
import { useEffect, useState, ReactNode } from 'react'; import { useEffect, useRef, useState, ReactNode } from 'react';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useAuthSession } from '@/hooks/useAuth'; import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
@ -11,29 +11,35 @@ export function AuthLoader({ children }: { children: ReactNode }) {
const { refresh: refreshRateLimit } = useAuthRateLimit(); const { refresh: refreshRateLimit } = useAuthRateLimit();
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false);
const [isCheckingSignOut, setIsCheckingSignOut] = useState(true); const [bootstrapError, setBootstrapError] = useState<string | null>(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(() => { 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 () => { const checkStatus = async () => {
// If auth is disabled, stop checking immediately if (!authEnabled) return;
if (!authEnabled) {
setIsCheckingSignOut(false);
return;
}
// If session is still loading, wait
if (isPending) return; if (isPending) return;
// If we have a session, we are done checking
if (session) { if (session) {
setIsCheckingSignOut(false); attemptedForNullSessionRef.current = false;
setBootstrapError(null);
return; 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); setIsAutoLoggingIn(true);
setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode setBootstrapError(null);
try { try {
const client = getAuthClient(baseUrl); const client = getAuthClient(baseUrl);
@ -41,27 +47,44 @@ export function AuthLoader({ children }: { children: ReactNode }) {
await refreshRateLimit(); await refreshRateLimit();
} catch (err) { } catch (err) {
console.error('Auto-login failed', err); console.error('Auto-login failed', err);
setBootstrapError('Unable to start an anonymous session.');
} finally { } finally {
setIsAutoLoggingIn(false); setIsAutoLoggingIn(false);
} }
}; };
checkStatus(); checkStatus();
}, [session, isPending, authEnabled, baseUrl, refreshRateLimit]); }, [session, isPending, authEnabled, baseUrl, refreshRateLimit, retryNonce]);
// Show loader if: // Show loader if:
// 1. Auth client is initializing (isPending) AND auth is enabled // 1. Auth client is initializing (isPending) AND auth is enabled
// 2. We are checking the sign-out status (Dexie) // 2. We are actively creating an anonymous session
// 3. We are actively auto-logging in const isLoading = authEnabled && (isPending || isAutoLoggingIn || !session);
const isLoading = (isPending && authEnabled) || isCheckingSignOut || isAutoLoggingIn;
if (isLoading) { if (isLoading) {
return ( return (
<div className="fixed inset-0 bg-base z-50 flex flex-col items-center justify-center gap-4"> <div className="fixed inset-0 bg-base z-50 flex flex-col items-center justify-center gap-4">
<LoadingSpinner className="w-8 h-8 text-accent" /> <LoadingSpinner className="w-8 h-8 text-accent" />
<p className="text-sm text-muted animate-pulse"> {bootstrapError ? (
{isAutoLoggingIn ? 'Logging in anonymously...' : 'Loading...'} <div className="flex flex-col items-center gap-3">
</p> <p className="text-sm text-muted text-center">{bootstrapError}</p>
<button
type="button"
onClick={() => {
attemptedForNullSessionRef.current = false;
setBootstrapError(null);
setRetryNonce((v) => v + 1);
}}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2"
>
Retry
</button>
</div>
) : (
<p className="text-sm text-muted animate-pulse">
{isAutoLoggingIn ? 'Starting anonymous session...' : 'Loading...'}
</p>
)}
</div> </div>
); );
} }

View file

@ -9,7 +9,7 @@ import {
TransitionChild, TransitionChild,
Button, Button,
} from '@headlessui/react'; } from '@headlessui/react';
import { useAuthSession } from '@/hooks/useAuth'; import { useAuthSession } from '@/hooks/useAuthSession';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
export default function ClaimDataModal() { export default function ClaimDataModal() {

View file

@ -2,26 +2,24 @@
import { Button } from '@headlessui/react'; import { Button } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useAuthSession } from '@/hooks/useAuth'; import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { clearSignedOut } from '@/lib/session-utils';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
export function UserMenu({ className = '' }: { className?: string }) { export function UserMenu({ className = '' }: { className?: string }) {
const { authEnabled, baseUrl } = useAuthConfig(); const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAuthRateLimit();
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
const router = useRouter(); const router = useRouter();
if (!authEnabled || isPending) return null; if (!authEnabled || isPending) return null;
const handleSignOut = async () => { const handleDisconnectAccount = async () => {
const client = getAuthClient(baseUrl); 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 client.signOut();
await clearSignedOut(); // AuthLoader will create the next anonymous session.
await client.signIn.anonymous();
await refreshRateLimit();
router.refresh(); router.refresh();
}; };
@ -30,12 +28,12 @@ export function UserMenu({ className = '' }: { className?: string }) {
<div className={`flex gap-2 ${className}`}> <div className={`flex gap-2 ${className}`}>
<Link href="/signin"> <Link href="/signin">
<Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"> <Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent">
{session?.user.isAnonymous ? 'Log In' : 'Sign In'} Connect
</Button> </Button>
</Link> </Link>
<Link href="/signup"> <Link href="/signup">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]"> <Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]">
Sign Up Create account
</Button> </Button>
</Link> </Link>
</div> </div>
@ -49,9 +47,9 @@ export function UserMenu({ className = '' }: { className?: string }) {
</span> </span>
<Button <Button
onClick={handleSignOut} onClick={handleDisconnectAccount}
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.09]" className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.09]"
title="Sign Out" title="Disconnect account"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>

View file

@ -8,7 +8,7 @@ import {
} from '@headlessui/react'; } from '@headlessui/react';
import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/utils/voice'; import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/kokoro';
export function VoicesControlBase({ export function VoicesControlBase({
availableVoices, availableVoices,
@ -135,4 +135,3 @@ export function VoicesControlBase({
</div> </div>
); );
} }

View file

@ -38,7 +38,7 @@ import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
import { withRetry, generateTTS, alignAudio } from '@/lib/client'; import { withRetry, generateTTS, alignAudio } from '@/lib/client';
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp';
import { isKokoroModel } from '@/utils/voice'; import { isKokoroModel } from '@/lib/kokoro';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import type { import type {
TTSLocation, TTSLocation,

View file

@ -1,8 +1,7 @@
/** /**
* Voice Utilities * Kokoro Utilities
* *
* This module provides utilities for handling voice selection and management, * Utilities for handling Kokoro multi-voice syntax.
* particularly for Kokoro multi-voice syntax.
*/ */
/** /**

View file

@ -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<boolean> {
const config = await getAppConfig();
return config?.signedOut ?? false;
}
/**
* Clear the signed-out flag after displaying the message
*/
export async function clearSignedOut(): Promise<void> {
await updateAppConfig({ signedOut: false });
}
/**
* Mark that the user explicitly signed out
*/
export async function markSignedOut(): Promise<void> {
await updateAppConfig({ signedOut: true });
}

View file

@ -30,7 +30,6 @@ export interface AppConfigValues {
epubWordHighlightEnabled: boolean; epubWordHighlightEnabled: boolean;
firstVisit: boolean; firstVisit: boolean;
documentListState: DocumentListState; documentListState: DocumentListState;
signedOut: boolean;
privacyAccepted: boolean; privacyAccepted: boolean;
} }
@ -65,7 +64,6 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
showHint: true, showHint: true,
viewMode: 'grid', viewMode: 'grid',
}, },
signedOut: false,
privacyAccepted: false, privacyAccepted: false,
}; };

View file

@ -166,7 +166,7 @@ export async function setupTest(page: Page) {
// If auth is enabled, establish an anonymous session BEFORE navigation. // If auth is enabled, establish an anonymous session BEFORE navigation.
// This keeps each test self-contained (no shared storageState) while ensuring // This keeps each test self-contained (no shared storageState) while ensuring
// server routes that require auth don't intermittently 401 during app startup. // 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 // Navigate to the home page before each test
await page.goto('/'); await page.goto('/');