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 { 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';
|
||||||
|
|
|
||||||
|
|
@ -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) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -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;
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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('/');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue