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:
parent
20111b1e36
commit
30ce65e747
27 changed files with 147 additions and 176 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
|
|
|
|||
Loading…
Reference in a new issue