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 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';

View file

@ -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) });
}
}
}

View file

@ -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;

View file

@ -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() {

View file

@ -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",

View file

@ -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() {
</div>
}
/>
<AuthLoader>
<>
<section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
<div className="max-w-7xl mx-auto">
<RateLimitBanner className="mb-6" />
<HomeContent />
</div>
</section>
</>
</AuthLoader>
<section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
<div className="max-w-7xl mx-auto">
<RateLimitBanner className="mb-6" />
<HomeContent />
</div>
</section>
</div>
);
}

View file

@ -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: () => <DocumentSkeleton />

View file

@ -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 (
<AuthRateLimitProvider authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
<ThemeProvider>
<ConfigProvider>
<DocumentProvider>
<TTSProvider>
<PDFProvider>
<EPUBProvider>
<HTMLProvider>
{children}
<PrivacyPopup authEnabled={authEnabled} />
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
<AuthLoader>
<ConfigProvider>
<DocumentProvider>
<TTSProvider>
<PDFProvider>
<EPUBProvider>
<HTMLProvider>
<>
{children}
<PrivacyModal authEnabled={authEnabled} />
</>
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
);

View file

@ -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<string | null>(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() {
<SessionExpiredLoader setSessionExpired={setSessionExpired} />
</Suspense>
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
<h1 className="text-xl font-semibold text-foreground">
{sessionExpired ? 'Session Expired' : 'Sign In'}
</h1>
<p className="text-sm text-muted mt-1">
{sessionExpired
? 'Please sign in again to continue'
: justSignedOut
? 'Sign in to continue'
: 'Enter your email below to login'}
</p>
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
<h1 className="text-xl font-semibold text-foreground">
{sessionExpired ? 'Session Expired' : 'Connect Account'}
</h1>
<p className="text-sm text-muted mt-1">
{sessionExpired
? 'Please sign in again to continue'
: 'Connect an email account to sync your data across devices'}
</p>
{/* Alerts */}
{(sessionExpired || justSignedOut) && (
{sessionExpired && (
<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">
{sessionExpired
? 'Your session has expired. Please sign in again.'
: "You've been signed out."}
Your session has expired. Please sign in again.
</p>
</div>
)}
@ -207,7 +191,7 @@ function SignInContent() {
<span className="text-sm text-foreground">Remember me</span>
</label>
{/* Sign In Button */}
{/* Connect Button */}
<Button
type="submit"
disabled={isAnyLoading}
@ -217,7 +201,7 @@ function SignInContent() {
focus:ring-offset-2 disabled:opacity-50 transform transition-transform
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>
{/* GitHub */}
@ -241,17 +225,17 @@ function SignInContent() {
)}
</Button>
{/* Guest */}
{/* Anonymous */}
<Button
type="button"
disabled={isAnyLoading}
onClick={handleGuestSignIn}
onClick={handleAnonymousContinue}
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
focus:ring-offset-2 disabled:opacity-50 border border-offbase
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>
</div>
@ -266,7 +250,7 @@ function SignInContent() {
<p className="text-xs text-muted">
By signing in, you agree to our{' '}
<button
onClick={() => showPrivacyPopup({ authEnabled })}
onClick={() => showPrivacyModal({ authEnabled })}
className="underline hover:text-foreground"
>
Privacy Policy

View file

@ -6,7 +6,7 @@ import { useRouter } 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 { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner';
import toast from 'react-hot-toast';
@ -229,7 +229,7 @@ export default function SignUpPage() {
<p className="text-xs text-muted">
By creating an account, you agree to our{' '}
<button
onClick={() => showPrivacyPopup({ authEnabled })}
onClick={() => showPrivacyModal({ authEnabled })}
className="underline hover:text-foreground"
>
Privacy Policy

View file

@ -1,7 +1,7 @@
'use client'
import { GithubIcon } from '@/components/icons/Icons'
import { showPrivacyPopup } from '@/components/privacy-popup'
import { showPrivacyModal } from '@/components/PrivacyModal'
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'
export function Footer() {
@ -23,7 +23,7 @@ export function Footer() {
<span className='w-full sm:w-fit'></span>
<button
type="button"
onClick={() => showPrivacyPopup({ authEnabled })}
onClick={() => showPrivacyModal({ authEnabled })}
className="font-bold hover:text-foreground transition-colors outline-none"
>
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;
interface PrivacyPopupProps {
interface PrivacyModalProps {
onAccept?: () => void;
authEnabled?: boolean;
}
function PrivacyPopupBody({
function PrivacyModalBody({
origin,
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 [origin, setOrigin] = useState('');
@ -188,7 +188,7 @@ export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProp
Privacy & Data Usage
</DialogTitle>
<PrivacyPopupBody origin={origin} authEnabled={authEnabled} />
<PrivacyModalBody origin={origin} authEnabled={authEnabled} />
<div className="mt-6 flex justify-end">
<Button
@ -215,10 +215,10 @@ export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProp
* Function to programmatically show the privacy popup
* 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
const container = document.createElement('div');
container.id = 'privacy-popup-container';
container.id = 'privacy-modal-container';
document.body.appendChild(container);
const origin = typeof window !== 'undefined' ? window.location.origin : '';
@ -278,7 +278,7 @@ export function showPrivacyPopup(options?: { authEnabled?: boolean }): void {
Privacy & Data Usage
</DialogTitle>
<PrivacyPopupBody origin={origin} authEnabled={authEnabled} />
<PrivacyModalBody origin={origin} authEnabled={authEnabled} />
<div className="mt-6 flex justify-end">
<Button

View file

@ -33,11 +33,10 @@ import { deleteServerDocuments } from '@/lib/client';
import { DocumentSelectionModal } from '@/components/DocumentSelectionModal';
import { BaseDocument } from '@/types/documents';
import { getAuthClient } from '@/lib/auth-client';
import { useAuthSession } from '@/hooks/useAuth';
import { clearSignedOut } from '@/lib/session-utils';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
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;
@ -86,7 +85,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAuthRateLimit();
const { data: session } = useAuthSession();
const router = useRouter();
@ -335,12 +333,10 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const handleSignOut = async () => {
const client = getAuthClient(authBaseUrl);
// Sign out of the authenticated account, then immediately start a fresh anonymous session.
// This avoids landing in a "no session" state that can be misinterpreted as a "Guest" user.
// "Sign out" here means: disconnect the email/social account and return to a fresh
// anonymous session. The app should not be able to end up truly signed out.
await client.signOut();
await clearSignedOut();
await client.signIn.anonymous();
await refreshRateLimit();
// AuthLoader will create the next anonymous session and refresh rate limit state.
router.refresh();
};
@ -352,8 +348,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
// Sign out locally
const client = getAuthClient(authBaseUrl);
await client.signOut();
// Clear the "signed out" flag so AuthLoader triggers auto-anon-login
clearSignedOut();
// After account deletion, we return to a fresh anonymous session.
// AuthLoader will create the session if one isn't present yet.
window.location.href = '/';
} catch (error) {
console.error('Failed to delete account:', error);
@ -436,7 +432,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</DialogTitle>
<Button
onClick={() => showPrivacyPopup({ authEnabled })}
onClick={() => showPrivacyModal({ authEnabled })}
className="text-sm font-medium text-muted hover:text-accent"
>
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>
@ -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
</Button>
<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">
<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]">
Log In
Connect
</Button>
</Link>
<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]">
Sign Up
Create account
</Button>
</Link>
</div>

View file

@ -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<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(() => {
// 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 (
<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" />
<p className="text-sm text-muted animate-pulse">
{isAutoLoggingIn ? 'Logging in anonymously...' : 'Loading...'}
</p>
{bootstrapError ? (
<div className="flex flex-col items-center gap-3">
<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>
);
}

View file

@ -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() {

View file

@ -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 }) {
<div className={`flex gap-2 ${className}`}>
<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">
{session?.user.isAnonymous ? 'Log In' : 'Sign In'}
Connect
</Button>
</Link>
<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]">
Sign Up
Create account
</Button>
</Link>
</div>
@ -49,9 +47,9 @@ export function UserMenu({ className = '' }: { className?: string }) {
</span>
<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]"
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">
<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';
import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons';
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({
availableVoices,
@ -135,4 +135,3 @@ export function VoicesControlBase({
</div>
);
}

View file

@ -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,

View file

@ -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.
*/
/**

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;
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,
};

View file

@ -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('/');