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:
parent
64825b626a
commit
9ba20c8a9e
35 changed files with 3780 additions and 199 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
better-auth_migrations/2026-01-24T20-06-28.748Z.sql
Normal file
13
better-auth_migrations/2026-01-24T20-06-28.748Z.sql
Normal 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
11
docker-entrypoint.sh
Executable 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 "$@"
|
||||
|
|
@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
|
|||
canvas: './empty-module.ts',
|
||||
},
|
||||
},
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -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",
|
||||
|
|
|
|||
1456
pnpm-lock.yaml
1456
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
36
src/app/api/account/delete/route.ts
Normal file
36
src/app/api/account/delete/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
11
src/app/api/auth/[...all]/route.ts
Normal file
11
src/app/api/auth/[...all]/route.ts
Normal 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;
|
||||
73
src/app/api/rate-limit/status/route.ts
Normal file
73
src/app/api/rate-limit/status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
286
src/app/signin/page.tsx
Normal 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'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
240
src/app/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
76
src/components/auth/AuthLoader.tsx
Normal file
76
src/components/auth/AuthLoader.tsx
Normal 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}</>;
|
||||
}
|
||||
48
src/components/auth/SessionManager.tsx
Normal file
48
src/components/auth/SessionManager.tsx
Normal 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;
|
||||
}
|
||||
66
src/components/auth/UserMenu.tsx
Normal file
66
src/components/auth/UserMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src/components/privacy-popup.tsx
Normal file
211
src/components/privacy-popup.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
85
src/components/rate-limit-banner.tsx
Normal file
85
src/components/rate-limit-banner.tsx
Normal 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;
|
||||
}
|
||||
206
src/components/rate-limit-provider.tsx
Normal file
206
src/components/rate-limit-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/contexts/AuthConfigContext.tsx
Normal file
33
src/contexts/AuthConfigContext.tsx
Normal 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
36
src/hooks/useAuth.ts
Normal 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
35
src/lib/auth-client.ts
Normal 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;
|
||||
17
src/lib/server/auth-config.ts
Normal file
17
src/lib/server/auth-config.ts
Normal 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
106
src/lib/server/auth.ts
Normal 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"];
|
||||
125
src/lib/server/db-adapter.ts
Normal file
125
src/lib/server/db-adapter.ts
Normal 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
45
src/lib/server/db.ts
Normal 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();
|
||||
230
src/lib/server/rate-limiter.ts
Normal file
230
src/lib/server/rate-limiter.ts
Normal 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
26
src/lib/session-utils.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue