feat(auth): add user authentication and rate limiting

- Implement user sign-in, sign-up, and account management using better-auth
- Add rate limiting for TTS API with daily character limits for authenticated and anonymous users
- Integrate SQLite and PostgreSQL database support for user sessions and data persistence
- Update UI components to include authentication flows, user menu, and privacy popup
- Modify Dockerfile and package.json for new dependencies and entrypoint script
- Bump version to v1.3.0
This commit is contained in:
Richard R 2026-01-24 17:36:11 -07:00
parent 64825b626a
commit 9ba20c8a9e
35 changed files with 3780 additions and 199 deletions

View file

@ -65,4 +65,9 @@ ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build
EXPOSE 3003
# Start the application
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["pnpm", "start"]

View file

@ -241,7 +241,12 @@ Optionally required for different features:
```
> Note: The base URL for the TTS API should be accessible and relative to the Next.js server
4. Start the development server:
4. Run SQLite creation:
```bash
npx @better-auth/cli migrate -y
```
5. Start the development server:
With pnpm (recommended):
```bash

View file

@ -0,0 +1,13 @@
create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null, "isAnonymous" boolean);
create table "session" ("id" text not null primary key, "expiresAt" timestamptz not null, "token" text not null unique, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade);
create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamptz, "refreshTokenExpiresAt" timestamptz, "scope" text, "password" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null);
create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamptz not null, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null);
create index "session_userId_idx" on "session" ("userId");
create index "account_userId_idx" on "account" ("userId");
create index "verification_identifier_idx" on "verification" ("identifier");

11
docker-entrypoint.sh Executable file
View file

@ -0,0 +1,11 @@
#!/bin/sh
set -e
# Run migrations if we have the SQLite file path set or default
if [ -z "$POSTGRES_URL" ]; then
echo "Running SQLite migrations..."
npx @better-auth/cli migrate -y
fi
# Start the application
exec "$@"

View file

@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
canvas: './empty-module.ts',
},
},
serverExternalPackages: ["better-sqlite3"],
};
export default nextConfig;

View file

@ -1,6 +1,6 @@
{
"name": "openreader-webui",
"version": "v1.2.1",
"version": "v1.3.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 3003",
@ -10,10 +10,13 @@
"test": "playwright test"
},
"dependencies": {
"@better-auth/cli": "1.4.17",
"@headlessui/react": "^2.2.9",
"@types/howler": "^2.2.12",
"@types/uuid": "^10.0.0",
"@vercel/analytics": "^1.6.1",
"better-auth": "^1.4.17",
"better-sqlite3": "^12.6.2",
"cmpstr": "^3.2.0",
"compromise": "^14.14.5",
"core-js": "^3.48.0",
@ -25,6 +28,7 @@
"next": "^15.5.9",
"openai": "^6.16.0",
"pdfjs-dist": "4.8.69",
"pg": "^8.17.2",
"react": "^19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@ -41,7 +45,9 @@
"@eslint/eslintrc": "^3.3.3",
"@playwright/test": "^1.58.0",
"@tailwindcss/typography": "^0.5.19",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.19.30",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.2",
@ -51,6 +57,9 @@
"typescript": "^5.9.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3"
],
"overrides": {
"lodash": "^4.17.23",
"@xmldom/xmldom": "^0.9.8",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { isAuthEnabled } from '@/lib/server/auth-config';
export async function DELETE() {
if (!isAuthEnabled() || !auth) {
return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 });
}
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// Directly delete user from database
await db.transaction(async (client) => {
// Deleting user usually cascades to sessions, accounts, etc if FKs are set up correctly
// But better-auth schemas do cascade.
await client.query('DELETE FROM "user" WHERE id = $1', [session.user.id]);
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete account:', error);
return NextResponse.json(
{ error: 'Failed to delete account' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,11 @@
import { auth } from "@/lib/server/auth"; // path to your auth file
import { toNextJsHandler } from "better-auth/next-js";
const handlers = auth
? toNextJsHandler(auth)
: {
POST: async () => new Response("Auth disabled", { status: 404 }),
GET: async () => new Response("Auth disabled", { status: 404 })
};
export const { POST, GET } = handlers;

View file

@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth';
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth-config';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
// If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return NextResponse.json({
allowed: true,
currentCount: 0,
limit: Infinity,
remainingChars: Infinity,
resetTime: tomorrow.toISOString(),
userType: 'unauthenticated',
authEnabled: false
});
}
// Get session from auth
const session = await auth.api.getSession({
headers: await headers()
});
// No session means unauthenticated
if (!session?.user) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return NextResponse.json({
allowed: true,
currentCount: 0,
limit: RATE_LIMITS.ANONYMOUS,
remainingChars: RATE_LIMITS.ANONYMOUS,
resetTime: tomorrow.toISOString(),
userType: 'unauthenticated',
authEnabled: true
});
}
const isAnonymous = !session.user.email || session.user.email === '';
const result = await rateLimiter.getCurrentUsage({
id: session.user.id,
isAnonymous
});
return NextResponse.json({
allowed: result.allowed,
currentCount: result.currentCount,
limit: result.limit,
remainingChars: result.remainingChars,
resetTime: result.resetTime.toISOString(),
userType: isAnonymous ? 'anonymous' : 'authenticated',
authEnabled: true
});
} catch (error) {
console.error('Error getting rate limit status:', error);
return NextResponse.json(
{ error: 'Failed to get rate limit status' },
{ status: 500 }
);
}
}

View file

@ -6,6 +6,10 @@ import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto';
import type { TTSRequestPayload } from '@/types/client';
import type { TTSError, TTSAudioBuffer } from '@/types/tts';
import { headers } from 'next/headers';
import { auth } from '@/lib/server/auth';
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
import { isAuthEnabled } from '@/lib/server/auth-config';
type CustomVoice = string;
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
@ -47,7 +51,7 @@ async function fetchTTSBufferWithRetry(
const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2);
// Retry on 429 and 5xx only; never retry aborts
for (;;) {
for (; ;) {
try {
const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal });
return await response.arrayBuffer();
@ -98,13 +102,9 @@ function makeCacheKey(input: {
export async function POST(req: NextRequest) {
try {
// Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai';
// Parse body first to get text for rate limiting
const body = (await req.json()) as TTSRequestPayload;
const { text, voice, speed, format, model: req_model, instructions } = body;
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
if (!text || !voice || !speed) {
const errorBody: TTSError = {
@ -113,6 +113,53 @@ export async function POST(req: NextRequest) {
};
return NextResponse.json(errorBody, { status: 400 });
}
// Auth and rate limiting check (only when auth is enabled)
if (isAuthEnabled() && auth) {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session?.user) {
return NextResponse.json(
{ code: 'UNAUTHORIZED', message: 'Authentication required' },
{ status: 401 }
);
}
const isAnonymous = !session.user.email || session.user.email === '';
const charCount = text.length;
// Check rate limit
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
{ id: session.user.id, isAnonymous },
charCount
);
if (!rateLimitResult.allowed) {
return NextResponse.json(
{
code: 'RATE_LIMIT_EXCEEDED',
message: 'Daily character limit exceeded',
currentCount: rateLimitResult.currentCount,
limit: rateLimitResult.limit,
remainingChars: rateLimitResult.remainingChars,
resetTime: rateLimitResult.resetTime.toISOString(),
userType: isAnonymous ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymous
? `Sign up to increase your limit from ${(RATE_LIMITS.ANONYMOUS / 1000).toFixed(0)}K to ${(RATE_LIMITS.AUTHENTICATED / 1_000_000).toFixed(0)}M characters per day`
: undefined
},
{ status: 429 }
);
}
}
// Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai';
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
// Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default
const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model;
const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
@ -125,10 +172,10 @@ export async function POST(req: NextRequest) {
const normalizedVoice = (
!isKokoroModel(model as string) && voice.includes('+')
? (voice.split('+')[0].trim())
: voice
? (voice.split('+')[0].trim())
: voice
) as SpeechCreateParams['voice'];
const createParams: ExtendedSpeechParams = {
model: model,
voice: normalizedVoice,
@ -214,7 +261,7 @@ export async function POST(req: NextRequest) {
}
});
} finally {
try { req.signal.removeEventListener('abort', onAbort); } catch {}
try { req.signal.removeEventListener('abort', onAbort); } catch { }
}
}
@ -248,7 +295,7 @@ export async function POST(req: NextRequest) {
try {
buffer = await entry.promise;
} finally {
try { req.signal.removeEventListener('abort', onAbort); } catch {}
try { req.signal.removeEventListener('abort', onAbort); } catch { }
}
return new NextResponse(buffer, {

View file

@ -5,6 +5,7 @@ import { Metadata } from "next";
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';
export const metadata: Metadata = {
title: "OpenReader WebUI",
@ -45,17 +46,23 @@ export const metadata: Metadata = {
},
};
// Force dynamic rendering so env vars are read at runtime, not build time
export const dynamic = 'force-dynamic';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
//const isDev = false;
export default function RootLayout({ children }: { children: ReactNode }) {
// Read auth config inside the component to ensure runtime evaluation
const authEnabled = isAuthEnabled();
const authBaseUrl = getAuthBaseUrl();
return (
<html lang="en">
<head>
<meta name="color-scheme" content="light dark" />
</head>
<body className="antialiased">
<Providers>
<Providers authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
<div className="app-shell min-h-screen flex flex-col bg-background">
<main className="flex-1 flex flex-col">
{children}

View file

@ -1,27 +1,32 @@
import { HomeContent } from '@/components/HomeContent';
import { SettingsModal } from '@/components/SettingsModal';
import { UserMenu } from '@/components/auth/UserMenu';
import { AuthLoader } from '@/components/auth/AuthLoader';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
export default function Home() {
return (
<div className="flex flex-col h-full w-full">
<SettingsModal />
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
</p>
</div>
</section>
<section className="flex-1 px-4 pb-8 overflow-auto">
<div className="max-w-7xl mx-auto">
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
<HomeContent />
</div>
</section>
</div>
<AuthLoader>
<div className="flex flex-col h-full w-full">
<SettingsModal />
<UserMenu />
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
</p>
</div>
</section>
<section className="flex-1 px-4 pb-8 overflow-auto">
<div className="max-w-7xl mx-auto">
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
<HomeContent />
</div>
</section>
</div>
</AuthLoader>
);
}

View file

@ -9,9 +9,18 @@ import { TTSProvider } from '@/contexts/TTSContext';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { HTMLProvider } from '@/contexts/HTMLContext';
import { RateLimitProvider } from '@/components/rate-limit-provider';
import { PrivacyPopup } from '@/components/privacy-popup';
import { AuthConfigProvider } from '@/contexts/AuthConfigContext';
export function Providers({ children }: { children: ReactNode }) {
return (
interface ProvidersProps {
children: ReactNode;
authEnabled: boolean;
authBaseUrl: string | null;
}
export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps) {
const content = (
<ThemeProvider>
<ConfigProvider>
<DocumentProvider>
@ -20,6 +29,7 @@ export function Providers({ children }: { children: ReactNode }) {
<EPUBProvider>
<HTMLProvider>
{children}
<PrivacyPopup />
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
@ -28,4 +38,15 @@ export function Providers({ children }: { children: ReactNode }) {
</ConfigProvider>
</ThemeProvider>
);
// Wrap with RateLimitProvider only when auth is enabled
const wrappedContent = authEnabled ? (
<RateLimitProvider>{content}</RateLimitProvider>
) : content;
return (
<AuthConfigProvider authEnabled={authEnabled} baseUrl={authBaseUrl}>
{wrappedContent}
</AuthConfigProvider>
);
}

286
src/app/signin/page.tsx Normal file
View file

@ -0,0 +1,286 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { Button, Input } from '@headlessui/react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { getAuthClient } from '@/lib/auth-client';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import { showPrivacyPopup } from '@/components/privacy-popup';
import { wasSignedOut, clearSignedOut } from '@/lib/session-utils';
import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams();
useEffect(() => {
const reason = searchParams.get('reason');
setSessionExpired(reason === 'expired');
}, [searchParams, setSessionExpired]);
return null;
}
function SignInContent() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGithub, setLoadingGithub] = useState(false);
const [loadingGuest, setLoadingGuest] = 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 isAnyLoading = loadingEmail || loadingGithub || loadingGuest;
// Check if auth is enabled, redirect home if not
useEffect(() => {
if (!authEnabled) {
router.push('/');
}
}, [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);
};
const handleSignIn = async () => {
setError(null);
if (!email.trim() || !validateEmail(email)) {
setError('Please enter a valid email address');
return;
}
if (!password.trim()) {
setError('Password is required');
return;
}
setLoadingEmail(true);
try {
const client = getAuthClient(baseUrl);
const result = await client.signIn.email({
email: email.trim(),
password,
rememberMe
});
if (result.error) {
const errorMessage = result.error.message || 'An unknown error occurred';
if (errorMessage.toLowerCase().includes('invalid') ||
errorMessage.toLowerCase().includes('credentials')) {
setError('Invalid email or password');
} else {
setError(errorMessage);
}
} else {
router.push('/');
}
} catch (err) {
console.error('Sign in error:', err);
setError('Unable to connect. Please try again.');
} finally {
setLoadingEmail(false);
}
};
const handleGithubSignIn = async () => {
setLoadingGithub(true);
try {
const client = getAuthClient(baseUrl);
await client.signIn.social({
provider: 'github',
callbackURL: '/'
});
} finally {
setLoadingGithub(false);
}
};
const handleGuestSignIn = async () => {
setLoadingGuest(true);
setError(null);
try {
const client = getAuthClient(baseUrl);
await client.signIn.anonymous();
router.push('/');
} catch (e) {
console.error('Anonymous sign-in failed:', e);
setError('Unable to continue as guest. Please try again.');
} finally {
setLoadingGuest(false);
}
};
if (!authEnabled) {
return null;
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<Suspense fallback={null}>
<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>
{/* Alerts */}
{(sessionExpired || justSignedOut) && (
<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."}
</p>
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="mt-6 space-y-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
<Input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder="me@example.com"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
<Input
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }}
placeholder="Password"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Remember Me */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="rounded border-muted text-accent focus:ring-accent"
/>
<span className="text-sm text-foreground">Remember me</span>
</label>
{/* Sign In Button */}
<Button
type="submit"
disabled={isAnyLoading}
onClick={handleSignIn}
className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background
hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent
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'}
</Button>
{/* GitHub */}
<Button
type="button"
disabled={isAnyLoading}
onClick={handleGithubSignIn}
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]
flex items-center justify-center gap-2"
>
{loadingGithub ? (
<LoadingSpinner className="w-4 h-4" />
) : (
<>
<GithubIcon className="w-4 h-4" />
Sign in with GitHub
</>
)}
</Button>
{/* Guest */}
<Button
type="button"
disabled={isAnyLoading}
onClick={handleGuestSignIn}
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'}
</Button>
</div>
{/* Footer */}
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2">
<p className="text-xs text-muted">
Don&apos;t have an account?{' '}
<Link href="/signup" className="underline hover:text-foreground">
Sign up
</Link>
</p>
<p className="text-xs text-muted">
By signing in, you agree to our{' '}
<button
onClick={() => showPrivacyPopup()}
className="underline hover:text-foreground"
>
Privacy Policy
</button>
</p>
</div>
</div>
</div>
);
}
export default function SignInPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-background">
<LoadingSpinner className="w-8 h-8" />
</div>
}>
<SignInContent />
</Suspense>
);
}

240
src/app/signup/page.tsx Normal file
View file

@ -0,0 +1,240 @@
'use client';
import { useState, useEffect } from 'react';
import { Button, Input } from '@headlessui/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { getAuthClient } from '@/lib/auth-client';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import { showPrivacyPopup } from '@/components/privacy-popup';
import { LoadingSpinner } from '@/components/Spinner';
import toast from 'react-hot-toast';
export default function SignUpPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirmation, setPasswordConfirmation] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { authEnabled, baseUrl } = useAuthConfig();
// Check if auth is enabled, redirect home if not
useEffect(() => {
if (!authEnabled) {
router.push('/');
}
}, [router, authEnabled]);
const validateEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const validatePassword = (password: string) => {
const checks = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /\d/.test(password),
special: /[!@#$%^&*(),.?":{}|<>]/.test(password)
};
const strength = Object.values(checks).filter(Boolean).length;
return { checks, strength };
};
const handleSignUp = async () => {
setError(null);
if (!email.trim() || !validateEmail(email)) {
setError('Please enter a valid email address');
return;
}
const { strength } = validatePassword(password);
if (strength < 3) {
setError('Password is too weak');
return;
}
if (password !== passwordConfirmation) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
const client = getAuthClient(baseUrl);
const result = await client.signUp.email({
email: email.trim(),
password,
name: email.trim().split('@')[0], // Use part of email as name
});
if (result.error) {
const errorMessage = result.error.message || 'An unknown error occurred';
if (errorMessage.toLowerCase().includes('already exists')) {
setError('An account with this email already exists');
} else {
setError(errorMessage);
}
} else {
// Auto sign in
const signInResult = await client.signIn.email({ email: email.trim(), password });
if (signInResult.error) {
toast.success('Account created! Please sign in.');
router.push('/signin');
} else {
toast.success('Account created successfully!');
router.push('/');
}
}
} catch (err) {
console.error('Signup error:', err);
setError('Unable to connect. Please try again.');
} finally {
setLoading(false);
}
};
if (!authEnabled) {
return null;
}
const { checks, strength } = validatePassword(password);
const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'];
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500'];
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
<h1 className="text-xl font-semibold text-foreground">Sign Up</h1>
<p className="text-sm text-muted mt-1">Create your account to get started</p>
{error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="mt-6 space-y-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="me@example.com"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-foreground"
>
{showPassword ? '👁️' : '👁️‍🗨️'}
</button>
</div>
{/* Password Strength */}
{password && (
<div className="mt-2 space-y-1">
<div className="flex gap-1">
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
className={`h-1 flex-1 rounded ${i < strength ? strengthColors[strength - 1] : 'bg-offbase'
}`}
/>
))}
</div>
<p className={`text-xs ${strength >= 3 ? 'text-green-600' : 'text-red-600'}`}>
{strengthLabels[strength - 1] || 'Very Weak'}
</p>
<div className="text-xs space-y-0.5 text-muted">
{Object.entries(checks).map(([key, passed]) => (
<div key={key} className={`flex items-center gap-1 ${passed ? 'text-green-600' : ''}`}>
<span>{passed ? '✓' : '○'}</span>
<span>
{key === 'length' && 'At least 8 characters'}
{key === 'uppercase' && 'Uppercase letter'}
{key === 'lowercase' && 'Lowercase letter'}
{key === 'number' && 'Number'}
{key === 'special' && 'Special character'}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">Confirm Password</label>
<Input
type={showPassword ? 'text' : 'password'}
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
placeholder="Confirm Password"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm
focus:outline-none focus:ring-2 focus:ring-accent"
/>
{passwordConfirmation && password && (
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-green-600' : 'text-red-600'}`}>
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
</p>
)}
</div>
{/* Sign Up Button */}
<Button
type="submit"
disabled={loading}
onClick={handleSignUp}
className="w-full rounded-lg bg-accent py-2 text-sm font-medium text-background
hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent
focus:ring-offset-2 disabled:opacity-50 transform transition-transform
duration-200 hover:scale-[1.02]"
>
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
</Button>
</div>
{/* Footer */}
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2">
<p className="text-xs text-muted">
Already have an account?{' '}
<Link href="/signin" className="underline hover:text-foreground">
Sign in
</Link>
</p>
<p className="text-xs text-muted">
By creating an account, you agree to our{' '}
<button
onClick={() => showPrivacyPopup()}
className="underline hover:text-foreground"
>
Privacy Policy
</button>
</p>
</div>
</div>
</div>
);
}

View file

@ -19,6 +19,7 @@ import {
TabPanels,
TabPanel,
} from '@headlessui/react';
import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
@ -31,6 +32,10 @@ import { THEMES } from '@/contexts/ThemeContext';
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 { markSignedOut, clearSignedOut } from '@/lib/session-utils';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -56,17 +61,17 @@ export function SettingsModal() {
const [isImportingLibrary, setIsImportingLibrary] = useState(false);
const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false);
const [selectionModalProps, setSelectionModalProps] = useState<{
title: string;
confirmLabel: string;
mode: 'library' | 'load' | 'save';
defaultSelected: boolean;
initialFiles?: BaseDocument[];
fetcher?: () => Promise<BaseDocument[]>;
title: string;
confirmLabel: string;
mode: 'library' | 'load' | 'save';
defaultSelected: boolean;
initialFiles?: BaseDocument[];
fetcher?: () => Promise<BaseDocument[]>;
}>({
title: '',
confirmLabel: '',
mode: 'library',
defaultSelected: false
title: '',
confirmLabel: '',
mode: 'library',
defaultSelected: false
});
const [showProgress, setShowProgress] = useState(false);
@ -76,7 +81,10 @@ export function SettingsModal() {
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false);
const [showClearServerConfirm, setShowClearServerConfirm] = useState(false);
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { data: session } = useAuthSession();
const ttsProviders = useMemo(() => [
{ id: 'custom-openai', name: 'Custom OpenAI-Like' },
@ -171,53 +179,53 @@ export function SettingsModal() {
const pdfs = await getAllPdfDocuments();
const epubs = await getAllEpubDocuments();
const htmls = await getAllHtmlDocuments();
const allDocs: BaseDocument[] = [
...pdfs.map(d => ({ ...d, type: 'pdf' as const })),
...epubs.map(d => ({ ...d, type: 'epub' as const })),
...htmls.map(d => ({ ...d, type: 'html' as const }))
...pdfs.map(d => ({ ...d, type: 'pdf' as const })),
...epubs.map(d => ({ ...d, type: 'epub' as const })),
...htmls.map(d => ({ ...d, type: 'html' as const }))
];
setSelectionModalProps({
title: 'Save to Server',
confirmLabel: 'Save',
mode: 'save',
defaultSelected: true,
initialFiles: allDocs
title: 'Save to Server',
confirmLabel: 'Save',
mode: 'save',
defaultSelected: true,
initialFiles: allDocs
});
setIsSelectionModalOpen(true);
};
const handleLoad = async () => {
setSelectionModalProps({
title: 'Load from Server',
confirmLabel: 'Load',
mode: 'load',
defaultSelected: true,
fetcher: async () => {
const res = await fetch('/api/documents?list=true');
if (!res.ok) throw new Error('Failed to list server documents');
const data = await res.json();
// Handle case where API might return error object
if (data.error) throw new Error(data.error);
return data.documents || [];
}
title: 'Load from Server',
confirmLabel: 'Load',
mode: 'load',
defaultSelected: true,
fetcher: async () => {
const res = await fetch('/api/documents?list=true');
if (!res.ok) throw new Error('Failed to list server documents');
const data = await res.json();
// Handle case where API might return error object
if (data.error) throw new Error(data.error);
return data.documents || [];
}
});
setIsSelectionModalOpen(true);
};
const handleImportLibrary = async () => {
setSelectionModalProps({
title: 'Import from Library',
confirmLabel: 'Import',
mode: 'library',
defaultSelected: false,
fetcher: async () => {
const res = await fetch('/api/documents/library?limit=10000');
if (!res.ok) throw new Error('Failed to list library documents');
const data = await res.json();
return data.documents || [];
}
title: 'Import from Library',
confirmLabel: 'Import',
mode: 'library',
defaultSelected: false,
fetcher: async () => {
const res = await fetch('/api/documents/library?limit=10000');
if (!res.ok) throw new Error('Failed to list library documents');
const data = await res.json();
return data.documents || [];
}
});
setIsSelectionModalOpen(true);
};
@ -225,9 +233,9 @@ export function SettingsModal() {
const handleModalConfirm = async (selectedFiles: BaseDocument[]) => {
const controller = new AbortController();
setAbortController(controller);
const mode = selectionModalProps.mode;
// Close modal? Maybe keep open until started?
// Let's close it here, process starts.
// Actually we keep it open if we want to show loading state INSIDE modal?
@ -236,57 +244,57 @@ export function SettingsModal() {
setIsSelectionModalOpen(false);
try {
setShowProgress(true);
setProgress(0);
if (mode === 'save') {
setIsSyncing(true);
setOperationType('sync');
setStatusMessage('Preparing documents...');
await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => {
if (controller.signal.aborted) return;
setProgress(progress);
if (status) setStatusMessage(status);
}, controller.signal);
} else if (mode === 'load') {
setIsLoading(true);
setOperationType('load');
setStatusMessage('Downloading documents...');
// Need ids
const ids = selectedFiles.map(f => f.id);
await loadSelectedDocumentsFromServer(ids, (progress, status) => {
if (controller.signal.aborted) return;
setProgress(progress);
if (status) setStatusMessage(status);
}, controller.signal);
if (!controller.signal.aborted) setStatusMessage('Documents loaded');
} else if (mode === 'library') {
setIsImportingLibrary(true);
setOperationType('library');
setStatusMessage('Importing selected documents...');
await importSelectedDocuments(selectedFiles, (progress, status) => {
if (controller.signal.aborted) return;
setProgress(progress);
if (status) setStatusMessage(status);
}, controller.signal);
}
setShowProgress(true);
setProgress(0);
if (mode === 'save') {
setIsSyncing(true);
setOperationType('sync');
setStatusMessage('Preparing documents...');
await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => {
if (controller.signal.aborted) return;
setProgress(progress);
if (status) setStatusMessage(status);
}, controller.signal);
} else if (mode === 'load') {
setIsLoading(true);
setOperationType('load');
setStatusMessage('Downloading documents...');
// Need ids
const ids = selectedFiles.map(f => f.id);
await loadSelectedDocumentsFromServer(ids, (progress, status) => {
if (controller.signal.aborted) return;
setProgress(progress);
if (status) setStatusMessage(status);
}, controller.signal);
if (!controller.signal.aborted) setStatusMessage('Documents loaded');
} else if (mode === 'library') {
setIsImportingLibrary(true);
setOperationType('library');
setStatusMessage('Importing selected documents...');
await importSelectedDocuments(selectedFiles, (progress, status) => {
if (controller.signal.aborted) return;
setProgress(progress);
if (status) setStatusMessage(status);
}, controller.signal);
}
} catch (error) {
if (controller.signal.aborted) {
console.log(`${mode} operation cancelled`);
setStatusMessage('Operation cancelled');
} else {
console.error(`${mode} failed:`, error);
setStatusMessage(`${mode} failed. Please try again.`);
}
if (controller.signal.aborted) {
console.log(`${mode} operation cancelled`);
setStatusMessage('Operation cancelled');
} else {
console.error(`${mode} failed:`, error);
setStatusMessage(`${mode} failed. Please try again.`);
}
} finally {
setIsSyncing(false);
setIsLoading(false);
setIsImportingLibrary(false);
setShowProgress(false);
setProgress(0);
setStatusMessage('');
setAbortController(null);
setIsSyncing(false);
setIsLoading(false);
setIsImportingLibrary(false);
setShowProgress(false);
setProgress(0);
setStatusMessage('');
setAbortController(null);
}
};
@ -306,6 +314,32 @@ export function SettingsModal() {
setShowClearServerConfirm(false);
};
const handleSignOut = async () => {
await markSignedOut();
const client = getAuthClient(authBaseUrl);
await client.signOut();
window.location.reload();
};
const handleDeleteAccount = async () => {
try {
const res = await fetch('/api/account/delete', { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete account');
// Sign out locally
const client = getAuthClient(authBaseUrl);
await client.signOut();
// Clear the "signed out" flag so AuthLoader triggers auto-anon-login
clearSignedOut();
window.location.href = '/';
} catch (error) {
console.error('Failed to delete account:', error);
}
setShowDeleteAccountConfirm(false);
};
const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => {
if (type === 'apiKey') {
setLocalApiKey(value === '' ? '' : value);
@ -330,8 +364,9 @@ export function SettingsModal() {
const tabs = [
{ name: 'API', icon: '🔑' },
{ name: 'Appearance', icon: '✨' },
{ name: 'Documents', icon: '📄' }
{ name: 'Theme', icon: '✨' },
{ name: 'Docs', icon: '📄' },
...(authEnabled ? [{ name: 'User', icon: '👤' }] : [])
];
return (
@ -441,8 +476,7 @@ export function SettingsModal() {
<ListboxOption
key={provider.id}
className={({ active }) =>
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
active ? 'bg-offbase text-accent' : 'text-foreground'
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
}`
}
value={provider}
@ -516,8 +550,7 @@ export function SettingsModal() {
<ListboxOption
key={model.id}
className={({ active }) =>
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
active ? 'bg-offbase text-accent' : 'text-foreground'
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
}`
}
value={model}
@ -599,13 +632,14 @@ export function SettingsModal() {
setModelValue('kokoro');
setCustomModelInput('');
setLocalTTSInstructions('');
setLocalTTSInstructions('');
}}
>
Reset
</Button>
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
@ -745,6 +779,74 @@ export function SettingsModal() {
</div>
</div>
</TabPanel>
{authEnabled && (
<TabPanel className="space-y-4">
<div className="space-y-4">
<div className="rounded-lg bg-offbase p-4 space-y-3">
<h4 className="font-medium text-foreground">Current Session</h4>
<div className="text-sm space-y-1">
<p className="text-muted">Logged in as:</p>
<p className="font-medium text-foreground">{session?.user?.name || 'Guest'}</p>
<p className="text-xs text-muted font-mono">{session?.user?.email}</p>
{session?.user?.isAnonymous && (
<p className="text-xs text-accent mt-1">Anonymous / Guest Account</p>
)}
</div>
</div>
<div className="space-y-3 pt-2">
{!session?.user?.isAnonymous ? (
<>
<Button
onClick={handleSignOut}
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
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
>
Sign Out
</Button>
<div className="pt-4 border-t border-offbase">
<h4 className="text-sm font-medium text-red-500 mb-2">Danger Zone</h4>
<Button
onClick={() => setShowDeleteAccountConfirm(true)}
className="w-full justify-center rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm
font-medium text-red-600 dark:text-red-400 hover:bg-red-500/20 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
>
Delete Account
</Button>
<p className="text-xs text-muted mt-2 text-center">
This will permanently delete your account and data. You will be returned to a fresh guest session.
</p>
</div>
</>
) : (
<div className="pt-2 border-t border-offbase">
<p className="text-sm text-muted mb-3">
You are using a temporary guest account. Sign up to save your progress permanently.
</p>
<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">
Log In
</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">
Sign Up
</Button>
</Link>
</div>
</div>
)}
</div>
</div>
</TabPanel>
)}
</TabPanels>
</TabGroup>
</DialogPanel>
@ -752,7 +854,7 @@ export function SettingsModal() {
</div>
</div>
</Dialog>
</Transition>
</Transition >
<ConfirmDialog
isOpen={showClearLocalConfirm}
@ -773,8 +875,18 @@ export function SettingsModal() {
confirmText="Delete"
isDangerous={true}
/>
<ProgressPopup
<ConfirmDialog
isOpen={showDeleteAccountConfirm}
onClose={() => setShowDeleteAccountConfirm(false)}
onConfirm={handleDeleteAccount}
title="Delete Account"
message="Are you sure you want to delete your account? This action cannot be undone and all your data will be lost."
confirmText="Delete Account"
isDangerous={true}
/>
<ProgressPopup
isOpen={showProgress}
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
@ -795,7 +907,7 @@ export function SettingsModal() {
operationType={operationType}
cancelText="Cancel"
/>
<DocumentSelectionModal
<DocumentSelectionModal
isOpen={isSelectionModalOpen}
onClose={() => !isImportingLibrary && !isSyncing && !isLoading && setIsSelectionModalOpen(false)}
onConfirm={handleModalConfirm}

View file

@ -1,7 +1,11 @@
// Loading spinner component
export function LoadingSpinner() {
interface SpinnerProps {
className?: string;
}
export function LoadingSpinner({ className }: SpinnerProps = {}) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className={className || "absolute inset-0 flex items-center justify-center"}>
<div className="animate-spin h-4 w-4 border-2 border-foreground border-t-transparent rounded-full" />
</div>
);

View file

@ -0,0 +1,76 @@
'use client';
import { useEffect, useState, ReactNode } from 'react';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import { useAuthSession } from '@/hooks/useAuth';
import { getAuthClient } from '@/lib/auth-client';
import { wasSignedOut } from '@/lib/session-utils';
import { LoadingSpinner } from '@/components/Spinner';
export function AuthLoader({ children }: { children: ReactNode }) {
const { authEnabled, baseUrl } = useAuthConfig();
const { data: session, isPending } = useAuthSession();
const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false);
const [isCheckingSignOut, setIsCheckingSignOut] = useState(true);
useEffect(() => {
// Determine if we need to check sign-out status or proceed
const checkStatus = async () => {
// If auth is disabled, stop checking immediately
if (!authEnabled) {
setIsCheckingSignOut(false);
return;
}
// If session is still loading, wait
if (isPending) return;
// If we have a session, we are done checking
if (session) {
setIsCheckingSignOut(false);
return;
}
// No session, check if explicitly signed out
const userWasSignedOut = await wasSignedOut();
if (userWasSignedOut) {
setIsCheckingSignOut(false); // Render children unauthenticated
return;
}
// Not signed out, start auto-login
setIsAutoLoggingIn(true);
setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode
try {
const client = getAuthClient(baseUrl);
await client.signIn.anonymous();
} catch (err) {
console.error('Auto-login failed', err);
} finally {
setIsAutoLoggingIn(false);
}
};
checkStatus();
}, [session, isPending, authEnabled, baseUrl]);
// 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;
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>
</div>
);
}
return <>{children}</>;
}

View file

@ -0,0 +1,48 @@
'use client';
import { useEffect, useRef } from 'react';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import { useAuthSession } from '@/hooks/useAuth';
import { getAuthClient } from '@/lib/auth-client';
import { wasSignedOut } from '@/lib/session-utils';
export function SessionManager() {
const { authEnabled, baseUrl } = useAuthConfig();
const { data: session, isPending } = useAuthSession();
const attemptRef = useRef(false);
useEffect(() => {
// Only run if auth is enabled
if (!authEnabled) return;
// Wait for session check to complete
if (isPending) return;
// If we have a session, we're good
if (session) return;
// Prevent multiple attempts
if (attemptRef.current) return;
const checkAndSignIn = async () => {
attemptRef.current = true;
try {
// Check if user explicitly signed out
const signedOut = await wasSignedOut();
// If not explicitly signed out, sign in anonymously
if (!signedOut) {
console.log('No session found, signing in anonymously...');
const client = getAuthClient(baseUrl);
await client.signIn.anonymous();
}
} catch (error) {
console.error('Error in session manager:', error);
}
};
checkAndSignIn();
}, [session, isPending, authEnabled, baseUrl]);
return null;
}

View file

@ -0,0 +1,66 @@
'use client';
import { Button } from '@headlessui/react';
import Link from 'next/link';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import { useAuthSession } from '@/hooks/useAuth';
import { getAuthClient } from '@/lib/auth-client';
import { markSignedOut } from '@/lib/session-utils';
import { useRouter } from 'next/navigation';
export function UserMenu() {
const { authEnabled, baseUrl } = useAuthConfig();
const { data: session, isPending } = useAuthSession();
const router = useRouter();
if (!authEnabled || isPending) return null;
const handleSignOut = async () => {
await markSignedOut();
const client = getAuthClient(baseUrl);
await client.signOut();
router.refresh();
};
if (!session || session.user.isAnonymous) {
return (
<div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex gap-2">
<Link href="/signin">
<Button className="rounded-lg bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent hover:bg-accent/20">
{session?.user.isAnonymous ? 'Log In ' : 'Sign In'}
</Button>
</Link>
<Link href="/signup" className="hidden sm:block">
<Button className="rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-background hover:bg-secondary-accent">
Sign Up
</Button>
</Link>
</div>
);
}
return (
<div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex items-center gap-3">
<div className="hidden sm:flex flex-col items-end">
<span className="text-xs font-medium text-foreground">
{session.user.name || 'Guest'}
</span>
<span className="text-[10px] text-muted truncate max-w-[120px]">
{session.user.email}
</span>
</div>
<Button
onClick={handleSignOut}
className="p-2 text-foreground/70 hover:text-red-500 transition-colors"
title="Sign Out"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</Button>
</div>
);
}

View file

@ -0,0 +1,211 @@
'use client';
import { Fragment, useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
Button,
} from '@headlessui/react';
import { updateAppConfig, getAppConfig } from '@/lib/dexie';
interface PrivacyPopupProps {
onAccept?: () => void;
}
export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
const [isOpen, setIsOpen] = useState(false);
const checkPrivacyAccepted = useCallback(async () => {
const config = await getAppConfig();
if (!config?.privacyAccepted) {
setIsOpen(true);
}
}, []);
useEffect(() => {
checkPrivacyAccepted();
}, [checkPrivacyAccepted]);
const handleAccept = async () => {
await updateAppConfig({ privacyAccepted: true });
setIsOpen(false);
onAccept?.();
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => { }}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
Privacy & Data Usage
</DialogTitle>
<div className="mt-4 space-y-3 text-sm text-foreground/90">
<p>Documents are uploaded to your local browser cache.</p>
<p>
Each paragraph of the document you are viewing is sent to Deepinfra
for audio generation through a Vercel backend proxy, containing a
shared caching pool.
</p>
<p>The audio is streamed back to your browser and played in real-time.</p>
<p className="font-semibold italic">
Self-hosting is the recommended way to use this app for a truly secure experience.
</p>
<p className="text-xs text-muted">
This site uses Vercel Analytics to collect anonymous usage data to help improve the service.
</p>
</div>
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-4 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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={handleAccept}
>
I Understand
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
/**
* Function to programmatically show the privacy popup
* This can be called from signin/signup components
*/
export function showPrivacyPopup(): void {
// Create a temporary container for the popup
const container = document.createElement('div');
container.id = 'privacy-popup-container';
document.body.appendChild(container);
// Import React and render the popup
import('react-dom/client').then(({ createRoot }) => {
import('react').then((React) => {
const root = createRoot(container);
const PopupWrapper = () => {
const [show, setShow] = useState(true);
const handleClose = () => {
setShow(false);
setTimeout(() => {
root.unmount();
container.remove();
}, 300);
};
if (!show) return null;
return (
<Transition appear show={show} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleClose}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
Privacy & Data Usage
</DialogTitle>
<div className="mt-4 space-y-3 text-sm text-foreground/90">
<p>Documents are uploaded to your local browser cache.</p>
<p>
Each paragraph of the document you are viewing is sent to Deepinfra
for audio generation through a Vercel backend proxy, containing a
shared caching pool.
</p>
<p>The audio is streamed back to your browser and played in real-time.</p>
<p className="font-semibold italic">
Self-hosting is the recommended way to use this app for a truly secure experience.
</p>
<p className="text-xs text-muted">
This site uses Vercel Analytics to collect anonymous usage data.
</p>
</div>
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-4 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
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={handleClose}
>
Close
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
};
root.render(React.createElement(PopupWrapper));
});
});
}

View file

@ -0,0 +1,85 @@
'use client';
import { useRateLimit, formatCharCount } from '@/components/rate-limit-provider';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import Link from 'next/link';
interface RateLimitBannerProps {
className?: string;
}
export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const { status, isAtLimit, timeUntilReset } = useRateLimit();
const { authEnabled } = useAuthConfig();
// Don't show banner if auth is not enabled or if not at limit
if (!authEnabled || !status?.authEnabled || !isAtLimit) {
return null;
}
const isAnonymous = status.userType === 'anonymous';
return (
<div className={`bg-amber-500/10 border border-amber-500/30 rounded-lg p-4 ${className}`}>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="flex-1">
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
Daily TTS limit reached
</p>
<p className="text-xs text-amber-600 dark:text-amber-500 mt-1">
{`You've used ${formatCharCount(status.currentCount)} of ${formatCharCount(status.limit)} characters today.`}
{' Resets in '}{timeUntilReset}.
</p>
</div>
{isAnonymous && (
<Link
href="/signup"
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg
bg-accent text-background hover:bg-secondary-accent
transform transition-transform duration-200 hover:scale-[1.04]"
>
Sign up for 4x more
</Link>
)}
</div>
</div>
);
}
/**
* Compact version for inline display
*/
export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
const { status, isAtLimit } = useRateLimit();
const { authEnabled } = useAuthConfig();
// Don't show if auth is not enabled
if (!authEnabled || !status?.authEnabled) {
return null;
}
const percentage = status.limit === Infinity
? 0
: Math.min(100, (status.currentCount / status.limit) * 100);
const isWarning = percentage >= 80;
if (isAtLimit) {
return (
<span className={`text-xs font-medium text-amber-600 dark:text-amber-400 ${className}`}>
Limit reached
</span>
);
}
if (isWarning) {
return (
<span className={`text-xs text-muted ${className}`}>
{formatCharCount(status.remainingChars)} chars left
</span>
);
}
return null;
}

View file

@ -0,0 +1,206 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
export interface RateLimitStatus {
allowed: boolean;
currentCount: number;
limit: number;
remainingChars: number;
resetTime: Date;
userType: 'anonymous' | 'authenticated' | 'unauthenticated';
authEnabled: boolean;
}
export interface RateLimitContextType {
status: RateLimitStatus | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
isAtLimit: boolean;
timeUntilReset: string;
incrementCount: (charCount: number) => void;
onTTSStart: () => void;
onTTSComplete: () => void;
}
const RateLimitContext = createContext<RateLimitContextType | null>(null);
export function useRateLimit(): RateLimitContextType {
const context = useContext(RateLimitContext);
if (!context) {
throw new Error('useRateLimit must be used within a RateLimitProvider');
}
return context;
}
interface RateLimitProviderProps {
children: React.ReactNode;
}
function calculateTimeUntilReset(resetTime: Date): string {
const now = new Date();
const timeDiff = resetTime.getTime() - now.getTime();
if (timeDiff <= 0) {
return 'Soon';
}
const hours = Math.floor(timeDiff / (1000 * 60 * 60));
const minutes = Math.floor((timeDiff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
function formatCharCount(count: number): string {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
} else if (count >= 1_000) {
return `${(count / 1_000).toFixed(0)}K`;
}
return count.toString();
}
export { formatCharCount };
export function RateLimitProvider({ children }: RateLimitProviderProps) {
const [status, setStatus] = useState<RateLimitStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { authEnabled } = useAuthConfig();
// Track pending TTS operations to delay count updates
const pendingTTSRef = useRef<number>(0);
const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const fetchStatus = useCallback(async () => {
// Skip if auth is not enabled
if (!authEnabled) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
setStatus({
allowed: true,
currentCount: 0,
limit: Infinity,
remainingChars: Infinity,
resetTime: tomorrow,
userType: 'unauthenticated',
authEnabled: false
});
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await fetch('/api/rate-limit/status');
if (!response.ok) {
throw new Error(`Failed to fetch rate limit status: ${response.status}`);
}
const data = await response.json();
setStatus({
...data,
resetTime: new Date(data.resetTime)
});
} catch (err) {
console.error('Error fetching rate limit status:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [authEnabled]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Calculate time until reset
const timeUntilReset = status ? calculateTimeUntilReset(status.resetTime) : '';
const isAtLimit = status ? status.remainingChars <= 0 : false;
// Increment count locally (for immediate UI feedback)
const incrementCount = useCallback((charCount: number) => {
setStatus(prevStatus => {
if (!prevStatus) return prevStatus;
const newCurrentCount = prevStatus.currentCount + charCount;
const newRemainingChars = Math.max(0, prevStatus.limit - newCurrentCount);
return {
...prevStatus,
currentCount: newCurrentCount,
remainingChars: newRemainingChars,
allowed: newRemainingChars > 0
};
});
}, []);
// Called when a TTS request starts
const onTTSStart = useCallback(() => {
pendingTTSRef.current += 1;
// Clear any existing timeout
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
updateTimeoutRef.current = null;
}
}, []);
// Called when a TTS request completes (success or error)
const onTTSComplete = useCallback(() => {
pendingTTSRef.current = Math.max(0, pendingTTSRef.current - 1);
// Clear any existing timeout
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
updateTimeoutRef.current = null;
}
// If no more pending requests, schedule an update
if (pendingTTSRef.current === 0) {
updateTimeoutRef.current = setTimeout(() => {
fetchStatus();
updateTimeoutRef.current = null;
}, 1000); // Wait 1 second after completion to refresh
}
}, [fetchStatus]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
}
};
}, []);
const contextValue: RateLimitContextType = {
status,
loading,
error,
refresh: fetchStatus,
isAtLimit,
timeUntilReset,
incrementCount,
onTTSStart,
onTTSComplete
};
return (
<RateLimitContext.Provider value={contextValue}>
{children}
</RateLimitContext.Provider>
);
}

View file

@ -0,0 +1,33 @@
'use client';
import { createContext, useContext, ReactNode } from 'react';
interface AuthConfig {
authEnabled: boolean;
baseUrl: string | null;
}
const AuthConfigContext = createContext<AuthConfig>({
authEnabled: false,
baseUrl: null,
});
export function AuthConfigProvider({
children,
authEnabled,
baseUrl,
}: {
children: ReactNode;
authEnabled: boolean;
baseUrl: string | null;
}) {
return (
<AuthConfigContext.Provider value={{ authEnabled, baseUrl }}>
{children}
</AuthConfigContext.Provider>
);
}
export function useAuthConfig() {
return useContext(AuthConfigContext);
}

36
src/hooks/useAuth.ts Normal file
View file

@ -0,0 +1,36 @@
'use client';
import { useMemo } from 'react';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import { getAuthClient } from '@/lib/auth-client';
/**
* Hook that provides auth client methods configured with the correct baseUrl from server.
* Use this hook in components that need to call auth methods (signIn, signUp, etc.)
*/
export function useAuth() {
const { baseUrl, authEnabled } = useAuthConfig();
const client = useMemo(() => {
return getAuthClient(baseUrl);
}, [baseUrl]);
return {
authEnabled,
baseUrl,
signIn: client.signIn,
signUp: client.signUp,
signOut: client.signOut,
useSession: client.useSession,
getSession: client.getSession,
};
}
/**
* Hook for session that uses the correct baseUrl from context
*/
export function useAuthSession() {
const { baseUrl } = useAuthConfig();
const client = useMemo(() => getAuthClient(baseUrl), [baseUrl]);
return client.useSession();
}

35
src/lib/auth-client.ts Normal file
View file

@ -0,0 +1,35 @@
import { createAuthClient } from "better-auth/react";
import { anonymousClient } from "better-auth/client/plugins";
// Factory function to create auth client with specific baseUrl
function createAuthClientWithUrl(baseUrl: string) {
return createAuthClient({
baseURL: baseUrl,
plugins: [anonymousClient()],
});
}
// Cache for auth client instances by baseUrl
const clientCache = new Map<string, ReturnType<typeof createAuthClientWithUrl>>();
export function getAuthClient(baseUrl: string | null) {
const effectiveUrl = baseUrl || "http://localhost:3003";
if (!clientCache.has(effectiveUrl)) {
clientCache.set(effectiveUrl, createAuthClientWithUrl(effectiveUrl));
}
return clientCache.get(effectiveUrl)!;
}
// Default client for backwards compatibility (will use localhost in dev)
// Components should prefer useAuth() hook which gets baseUrl from context
export const authClient = getAuthClient(null);
export const {
signIn,
signUp,
signOut,
useSession,
getSession
} = authClient;

View file

@ -0,0 +1,17 @@
/**
* Centralized auth configuration check.
* Auth is only enabled when BOTH BETTER_AUTH_SECRET and BETTER_AUTH_URL are set.
*/
export function isAuthEnabled(): boolean {
return !!(process.env.BETTER_AUTH_SECRET && process.env.BETTER_AUTH_URL);
}
/**
* Get the auth base URL if auth is enabled, otherwise null.
*/
export function getAuthBaseUrl(): string | null {
if (!isAuthEnabled()) {
return null;
}
return process.env.BETTER_AUTH_URL || null;
}

106
src/lib/server/auth.ts Normal file
View file

@ -0,0 +1,106 @@
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { anonymous } from "better-auth/plugins";
import { Pool } from "pg";
import { rateLimiter } from "@/lib/server/rate-limiter";
import { isAuthEnabled } from "@/lib/server/auth-config";
const getAuthDatabase = () => {
if (process.env.POSTGRES_URL) {
return new Pool({
connectionString: process.env.POSTGRES_URL,
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
});
}
// Fallback to SQLite
// We need to dynamically import or require better-sqlite3 to avoid build issues if it's not used?
// Actually better-auth supports it directly, we just pass the instance.
/* eslint-disable @typescript-eslint/no-require-imports */
const Database = require("better-sqlite3");
const path = require("path");
const fs = require("fs");
/* eslint-enable @typescript-eslint/no-require-imports */
// Ensure directory exists
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return new Database(dbPath);
};
const createAuth = () => betterAuth({
database: getAuthDatabase(),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3003",
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // Set to true in production
async sendResetPassword(data) {
// Send an email to the user with a link to reset their password
console.log("Password reset requested for:", data.user.email);
},
},
socialProviders: {
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
}),
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days (reasonable for user experience)
updateAge: 60 * 60 * 1, // 1 hour (refresh more frequently)
cookieCache: {
maxAge: 60 * 60 * 24 * 7, // 7 days for cookie cache
},
},
advanced: {
database: {
generateId: () => {
// Generate user-friendly IDs similar to current system
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `user-${timestamp}-${random}`;
},
},
},
plugins: [
nextCookies(), // Enable Next.js cookie handling
anonymous({
onLinkAccount: async ({ anonymousUser, newUser }) => {
try {
// Log when anonymous user links to a real account
console.log("Anonymous user linked to account:", {
anonymousUserId: anonymousUser.user.id,
newUserId: newUser.user.id,
newUserEmail: newUser.user.email,
});
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
try {
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
} catch (error) {
console.error("Error transferring rate limit data during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
} catch (error) {
console.error("Error in onLinkAccount callback:", error);
// Don't throw here to prevent blocking the account linking process
}
// Note: Anonymous user will be automatically deleted after this callback completes
},
}),
],
});
export const auth = isAuthEnabled() ? createAuth() : null;
type AuthInstance = ReturnType<typeof createAuth>;
export type Session = AuthInstance["$Infer"]["Session"];
export type User = AuthInstance["$Infer"]["Session"]["user"];

View file

@ -0,0 +1,125 @@
import { Pool, PoolClient } from "pg";
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
/**
* Common interface for database operations to support both Postgres and SQLite
*/
export interface DBAdapter {
query(text: string, params?: unknown[]): Promise<{ rows: unknown[], rowCount?: number }>;
transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T>;
}
export class PostgresAdapter implements DBAdapter {
constructor(private pool: Pool) { }
async query(text: string, params?: unknown[]) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await this.pool.query(text, params as any[]);
return {
rows: result.rows,
rowCount: result.rowCount ?? undefined
};
} catch (error) {
console.error("Postgres Query Error:", error);
throw error;
}
}
async transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const connectedAdapter = new PostgresConnectedAdapter(client);
const result = await callback(connectedAdapter);
await client.query("COMMIT");
return result;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
}
class PostgresConnectedAdapter implements DBAdapter {
constructor(private client: PoolClient) { }
async query(text: string, params?: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await this.client.query(text, params as any[]);
return {
rows: result.rows,
rowCount: result.rowCount ?? undefined
};
}
async transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T> {
// Nested transactions not explicitly supported, just pass through
return callback(this);
}
}
export class SqliteAdapter implements DBAdapter {
private db: Database.Database;
constructor(filePath: string) {
// Ensure directory exists
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
try {
fs.mkdirSync(dir, { recursive: true });
console.log(`Created directory for SQLite: ${dir}`);
} catch (e) {
console.error(`Failed to create directory ${dir}:`, e);
}
}
this.db = new Database(filePath);
// Enable WAL mode for better concurrency
this.db.pragma('journal_mode = WAL');
}
async query(text: string, params?: unknown[]) {
// simple heuristic to convert Postgres $n params to SQLite ?
// This assumes we aren't using $n inside string literals.
const convertedSql = text.replace(/\$\d+/g, "?");
try {
const stmt = this.db.prepare(convertedSql);
const safeParams = params || [];
const lowerSql = convertedSql.trim().toLowerCase();
// If it's a SELECT or RETURNING, we use .all() or .get()
if (lowerSql.startsWith("select") || /returning\s+/.test(lowerSql)) {
const rows = stmt.all(...safeParams);
return { rows, rowCount: rows.length };
} else {
// INSERT/UPDATE/DELETE with no RETURNING
const info = stmt.run(...safeParams);
return { rows: [], rowCount: info.changes };
}
} catch (error) {
console.error("SQLite Query Error:", error);
throw error;
}
}
async transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T> {
// Manual transaction management
// better-sqlite3 is synchronous, but we simulate async interface
this.db.exec("BEGIN");
try {
const result = await callback(this);
this.db.exec("COMMIT");
return result;
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
}
}

45
src/lib/server/db.ts Normal file
View file

@ -0,0 +1,45 @@
import { Pool } from 'pg';
import path from 'path';
import { DBAdapter, PostgresAdapter, SqliteAdapter } from '@/lib/server/db-adapter';
import { isAuthEnabled } from '@/lib/server/auth-config';
// Singleton instance
let dbInstance: DBAdapter | null = null;
class NoOpAdapter implements DBAdapter {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async query(text: string, params?: unknown[]) {
return { rows: [], rowCount: 0 };
}
async transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T> {
return callback(this);
}
}
export function getDB(): DBAdapter {
if (dbInstance) return dbInstance;
// Avoid creating database connection/file if auth is disabled
if (!isAuthEnabled()) {
dbInstance = new NoOpAdapter();
return dbInstance;
}
if (process.env.POSTGRES_URL) {
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
});
dbInstance = new PostgresAdapter(pool);
} else {
// Fallback to SQLite
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
console.log(`Using SQLite database at ${dbPath}`);
dbInstance = new SqliteAdapter(dbPath);
}
return dbInstance!;
}
export const db = getDB();

View file

@ -0,0 +1,230 @@
import { db } from '@/lib/server/db';
import { isAuthEnabled } from '@/lib/server/auth-config';
// Rate limits configuration - character counts per day
export const RATE_LIMITS = {
ANONYMOUS: 250_000, // 250K characters per day for anonymous users
AUTHENTICATED: 1_000_000 // 1M characters per day for authenticated users
} as const;
// Initialize rate limiting table
export async function initializeRateLimitTable() {
// Use transaction to ensure safe initialization
await db.transaction(async (client) => {
// Check if table exists first to avoid errors on some DBs
// Simple create table if not exists
await client.query(`
CREATE TABLE IF NOT EXISTS user_tts_chars (
user_id VARCHAR(255) NOT NULL,
date DATE NOT NULL,
char_count BIGINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, date)
)
`);
// Create index for faster queries
await client.query(`
CREATE INDEX IF NOT EXISTS idx_user_tts_chars_date ON user_tts_chars(date)
`);
});
}
export interface RateLimitResult {
allowed: boolean;
currentCount: number;
limit: number;
resetTime: Date;
remainingChars: number;
}
interface DBCharCountRow {
char_count: string | number;
}
export interface UserInfo {
id: string;
isAnonymous?: boolean;
isPro?: boolean;
}
export class RateLimiter {
constructor() { }
/**
* Check if a user can use TTS and increment their char count if allowed
* @param charCount - Number of characters to add
*/
async checkAndIncrementLimit(user: UserInfo, charCount: number): Promise<RateLimitResult> {
// If auth is not enabled, always allow
if (!isAuthEnabled()) {
return {
allowed: true,
currentCount: 0,
limit: Infinity,
resetTime: this.getResetTime(),
remainingChars: Infinity
};
}
await initializeRateLimitTable();
return db.transaction(async (client) => {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const limit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
// Get or create today's record for this user
// Postgres supports RETURNING, but SQLite behavior can vary with ON CONFLICT
// We'll use a standard upsert pattern compatible with both via the adapter logic or simple queries
// First, ensure record exists
// SQLite/Postgres compatible UPSERT
await client.query(`
INSERT INTO user_tts_chars (user_id, date, char_count)
VALUES ($1, $2, 0)
ON CONFLICT (user_id, date)
DO UPDATE SET updated_at = CURRENT_TIMESTAMP
`, [user.id, today]);
// Get current count
const result = await client.query(`
SELECT char_count FROM user_tts_chars
WHERE user_id = $1 AND date = $2
`, [user.id, today]);
const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10);
// Check if adding these chars would exceed the limit
if (currentCount + charCount > limit) {
return {
allowed: false,
currentCount,
limit,
resetTime: this.getResetTime(),
remainingChars: Math.max(0, limit - currentCount)
};
}
// Increment the count
await client.query(`
UPDATE user_tts_chars
SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND date = $2
`, [user.id, today, charCount]);
const newCount = currentCount + charCount;
return {
allowed: true,
currentCount: newCount,
limit,
resetTime: this.getResetTime(),
remainingChars: Math.max(0, limit - newCount)
};
});
}
/**
* Get current usage for a user without incrementing
*/
async getCurrentUsage(user: UserInfo): Promise<RateLimitResult> {
// If auth is not enabled, return unlimited
if (!isAuthEnabled()) {
return {
allowed: true,
currentCount: 0,
limit: Infinity,
resetTime: this.getResetTime(),
remainingChars: Infinity
};
}
await initializeRateLimitTable();
const today = new Date().toISOString().split('T')[0];
const limit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const result = await db.query(
'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2',
[user.id, today]
);
const currentCount = result.rows.length > 0 ? parseInt(((result.rows[0] as unknown) as DBCharCountRow).char_count.toString(), 10) : 0;
return {
allowed: currentCount < limit,
currentCount,
limit,
resetTime: this.getResetTime(),
remainingChars: Math.max(0, limit - currentCount)
};
}
/**
* Transfer char counts when anonymous user creates an account
*/
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
if (!isAuthEnabled()) return;
await initializeRateLimitTable();
await db.transaction(async (client) => {
const today = new Date().toISOString().split('T')[0];
// Get anonymous user's current count
const anonymousResult = await client.query(
'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2',
[anonymousUserId, today]
);
if (anonymousResult.rows.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anonymousCount = parseInt((anonymousResult.rows[0] as any).char_count, 10);
// Update or create record for authenticated user
await client.query(`
INSERT INTO user_tts_chars (user_id, date, char_count)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, date)
DO UPDATE SET
char_count = CASE
WHEN user_tts_chars.char_count > $3 THEN user_tts_chars.char_count
ELSE $3
END,
updated_at = CURRENT_TIMESTAMP
`, [authenticatedUserId, today, anonymousCount]);
// Remove anonymous user's record
await client.query(
'DELETE FROM user_tts_chars WHERE user_id = $1 AND date = $2',
[anonymousUserId, today]
);
}
});
}
/**
* Clean up old records (optional maintenance)
*/
async cleanupOldRecords(daysToKeep: number = 30): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
await db.query(
'DELETE FROM user_tts_chars WHERE date < $1',
[cutoffDateStr]
);
}
private getResetTime(): Date {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0); // Start of next day
return tomorrow;
}
}
// Export singleton instance
export const rateLimiter = new RateLimiter();

26
src/lib/session-utils.ts Normal file
View file

@ -0,0 +1,26 @@
// Session utilities using Dexie for persistence
// Used by auth components for sign-out state tracking
import { updateAppConfig, getAppConfig } from '@/lib/dexie';
/**
* Check if user was explicitly signed out (for showing appropriate message on signin page)
*/
export async function wasSignedOut(): Promise<boolean> {
const config = await getAppConfig();
return config?.signedOut ?? false;
}
/**
* Clear the signed-out flag after displaying the message
*/
export async function clearSignedOut(): Promise<void> {
await updateAppConfig({ signedOut: false });
}
/**
* Mark that the user explicitly signed out
*/
export async function markSignedOut(): Promise<void> {
await updateAppConfig({ signedOut: true });
}

View file

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

View file

@ -45,7 +45,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
await uploadFile(page, fileName);
const lower = fileName.toLowerCase();
if (lower.endsWith('.docx')) {
await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible();
const pdfName = fileName.replace(/\.docx$/i, '.pdf');
@ -94,7 +94,7 @@ export async function playTTSAndWaitForASecond(page: Page, fileName: string) {
export async function pauseTTSAndVerify(page: Page) {
// Click pause to stop playback
await page.getByRole('button', { name: 'Pause' }).click();
// Check for play button to be visible
await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 });
}
@ -118,6 +118,8 @@ export async function setupTest(page: Page) {
// Click the "done" button to dismiss the welcome message
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'I Understand' }).click();
}
@ -303,8 +305,8 @@ export async function expectMediaState(page: Page, state: 'playing' | 'paused')
// Consider playing if not paused AND time has advanced at least a tiny amount
if (!audio.paused && curr > 0 && curr > last) return true;
} else {
// paused target
if (audio.paused) return true;
// paused target
if (audio.paused) return true;
}
}
return false;