-
- {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 */}
- {loadingEmail ? : 'Sign In'}
+ {loadingEmail ? : 'Connect'}
{/* GitHub */}
@@ -241,17 +225,17 @@ function SignInContent() {
)}
- {/* Guest */}
+ {/* Anonymous */}
- {loadingGuest ? : 'Continue as Guest'}
+ {loadingAnonymous ? : 'Continue anonymously'}
@@ -266,7 +250,7 @@ function SignInContent() {
By signing in, you agree to our{' '}
showPrivacyPopup({ authEnabled })}
+ onClick={() => showPrivacyModal({ authEnabled })}
className="underline hover:text-foreground"
>
Privacy Policy
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx
index 4764e4c..d18b0c2 100644
--- a/src/app/signup/page.tsx
+++ b/src/app/signup/page.tsx
@@ -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() {
By creating an account, you agree to our{' '}
showPrivacyPopup({ authEnabled })}
+ onClick={() => showPrivacyModal({ authEnabled })}
className="underline hover:text-foreground"
>
Privacy Policy
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 8468860..5c6685e 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -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() {
•
showPrivacyPopup({ authEnabled })}
+ onClick={() => showPrivacyModal({ authEnabled })}
className="font-bold hover:text-foreground transition-colors outline-none"
>
Privacy
diff --git a/src/components/privacy-popup.tsx b/src/components/PrivacyModal.tsx
similarity index 96%
rename from src/components/privacy-popup.tsx
rename to src/components/PrivacyModal.tsx
index 17a3bb4..ec300a5 100644
--- a/src/components/privacy-popup.tsx
+++ b/src/components/PrivacyModal.tsx
@@ -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
-
+
-
+
{
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 }) {
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 }) {
)}
>
) : (
- Not signed in
+ No active session
)}
@@ -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 }) {
- Log In
+ Connect
- Sign Up
+ Create account
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}
+
{
+ 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
+
+
+ ) : (
+
+ {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 }) {
- {session?.user.isAnonymous ? 'Log In' : 'Sign In'}
+ Connect
- Sign Up
+ Create account
@@ -49,9 +47,9 @@ export function UserMenu({ className = '' }: { className?: string }) {
diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx
index 92d7b87..b1dd415 100644
--- a/src/components/player/VoicesControlBase.tsx
+++ b/src/components/player/VoicesControlBase.tsx
@@ -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({
);
}
-
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('/');