diff --git a/.env.docker.example b/.env.docker.example index 6f2187a..bbc28e5 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -30,6 +30,11 @@ NEXTAUTH_SECRET="changethisinproduction" # Admin email - The first user registering with this email gets ADMIN role (REQUIRED) # ADMIN_EMAIL="admin@yourdomain.com" +# Google OAuth — both required to show "Continue with Google" on /login +# Redirect URI in Google Console: {NEXTAUTH_URL}/api/auth/callback/google +# AUTH_GOOGLE_ID="....apps.googleusercontent.com" +# AUTH_GOOGLE_SECRET="GOCSPX-..." + # ============================================================================= # POSTGRESQL CONFIGURATION # ============================================================================= diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 784bc18..e227e2f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -139,6 +139,8 @@ jobs: SEARXNG_URL: ${{ vars.SEARXNG_URL }} BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }} JINA_API_KEY: ${{ secrets.JINA_API_KEY }} + AUTH_GOOGLE_ID: ${{ vars.AUTH_GOOGLE_ID }} + AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }} run: | ENV_FILE="/opt/memento/.env.docker" touch "$ENV_FILE" @@ -181,6 +183,8 @@ jobs: upsert SEARXNG_URL "$SEARXNG_URL" upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY" upsert JINA_API_KEY "$JINA_API_KEY" + upsert AUTH_GOOGLE_ID "$AUTH_GOOGLE_ID" + upsert AUTH_GOOGLE_SECRET "$AUTH_GOOGLE_SECRET" - name: Deploy on 192.168.1.190 env: diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 2306147..f54ac67 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -52,6 +52,8 @@ jobs: SEARXNG_URL: ${{ vars.SEARXNG_URL }} BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }} JINA_API_KEY: ${{ secrets.JINA_API_KEY }} + AUTH_GOOGLE_ID: ${{ vars.AUTH_GOOGLE_ID }} + AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }} run: | ENV_FILE="/opt/memento/.env.docker" touch "$ENV_FILE" @@ -94,6 +96,8 @@ jobs: upsert SEARXNG_URL "$SEARXNG_URL" upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY" upsert JINA_API_KEY "$JINA_API_KEY" + upsert AUTH_GOOGLE_ID "$AUTH_GOOGLE_ID" + upsert AUTH_GOOGLE_SECRET "$AUTH_GOOGLE_SECRET" - name: Deploy (full build, no CI artifact) env: diff --git a/memento-note/.env.example b/memento-note/.env.example index 69ba8d3..370070d 100644 --- a/memento-note/.env.example +++ b/memento-note/.env.example @@ -19,6 +19,12 @@ NEXTAUTH_URL="http://localhost:3000" # Admin email - The first user registering with this email gets ADMIN role (REQUIRED) # ADMIN_EMAIL="admin@yourdomain.com" +# Google OAuth (optional — shows "Continue with Google" on login when both are set) +# Create credentials: https://console.cloud.google.com/apis/credentials +# Authorized redirect URI: {NEXTAUTH_URL}/api/auth/callback/google +# AUTH_GOOGLE_ID="....apps.googleusercontent.com" +# AUTH_GOOGLE_SECRET="GOCSPX-..." + # ----------------------------------------------------------------------------- # AI Providers # ----------------------------------------------------------------------------- diff --git a/memento-note/app/(admin)/admin/ai-test/ai-tester.tsx b/memento-note/app/(admin)/admin/ai-test/ai-tester.tsx index cd13a53..0df7bd6 100644 --- a/memento-note/app/(admin)/admin/ai-test/ai-tester.tsx +++ b/memento-note/app/(admin)/admin/ai-test/ai-tester.tsx @@ -51,10 +51,19 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) { try { const response = await fetch(`/api/ai/test-${type}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }) const endTime = Date.now() + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('application/json')) { + const bodyPreview = (await response.text()).slice(0, 200) + throw new Error( + response.status === 404 + ? `Route API introuvable (/api/ai/test-${type}). Redéployez l’application.` + : `Réponse non-JSON (HTTP ${response.status}): ${bodyPreview}`, + ) + } const data = await response.json() setResult({ diff --git a/memento-note/app/(auth)/login/page.tsx b/memento-note/app/(auth)/login/page.tsx index d110fac..3b45e0c 100644 --- a/memento-note/app/(auth)/login/page.tsx +++ b/memento-note/app/(auth)/login/page.tsx @@ -1,9 +1,15 @@ import { LoginForm } from '@/components/login-form'; import { getSystemConfig } from '@/lib/config'; +import { isGoogleAuthEnabled } from '@/lib/auth-providers'; export default async function LoginPage() { const config = await getSystemConfig(); const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false'; - return ; + return ( + + ); } diff --git a/memento-note/app/(auth)/register/page.tsx b/memento-note/app/(auth)/register/page.tsx index a62de53..f361272 100644 --- a/memento-note/app/(auth)/register/page.tsx +++ b/memento-note/app/(auth)/register/page.tsx @@ -1,5 +1,6 @@ import { RegisterForm } from '@/components/register-form'; import { getSystemConfig } from '@/lib/config'; +import { isGoogleAuthEnabled } from '@/lib/auth-providers'; import { redirect } from 'next/navigation'; export default async function RegisterPage() { @@ -10,5 +11,5 @@ export default async function RegisterPage() { redirect('/login'); } - return ; + return ; } diff --git a/memento-note/app/api/ai/test-chat/route.ts b/memento-note/app/api/ai/test-chat/route.ts new file mode 100644 index 0000000..29db0b8 --- /dev/null +++ b/memento-note/app/api/ai/test-chat/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server' +import { getChatProvider } from '@/lib/ai/factory' +import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' + +export async function POST() { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as { role?: string }).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + try { + const config = await getSystemConfig() + const provider = getChatProvider(config) + + const testMessage = 'Reply in exactly 3 words: what is your name?' + + const startTime = Date.now() + const response = await provider.generateText(testMessage) + const endTime = Date.now() + + if (!response || response.trim().length === 0) { + return NextResponse.json( + { + success: false, + error: 'No response from chat provider', + provider: config.AI_PROVIDER_CHAT || config.AI_PROVIDER_TAGS || 'ollama', + model: config.AI_MODEL_CHAT || 'granite4:latest', + }, + { status: 500 }, + ) + } + + return NextResponse.json({ + success: true, + provider: config.AI_PROVIDER_CHAT || config.AI_PROVIDER_TAGS || 'ollama', + model: config.AI_MODEL_CHAT || 'granite4:latest', + chatResponse: response.trim(), + responseTime: endTime - startTime, + }) + } catch (error: unknown) { + const config = await getSystemConfig() + const message = error instanceof Error ? error.message : 'Unknown error' + + return NextResponse.json( + { + success: false, + error: message, + provider: config.AI_PROVIDER_CHAT || config.AI_PROVIDER_TAGS || 'ollama', + model: config.AI_MODEL_CHAT || 'granite4:latest', + stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined, + }, + { status: 500 }, + ) + } +} diff --git a/memento-note/app/api/ai/test-embeddings/route.ts b/memento-note/app/api/ai/test-embeddings/route.ts new file mode 100644 index 0000000..ec7c8c1 --- /dev/null +++ b/memento-note/app/api/ai/test-embeddings/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server' +import { getEmbeddingsProvider } from '@/lib/ai/factory' +import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' + +function getProviderDetails(config: Record, providerType: string) { + const provider = providerType.toLowerCase() + + switch (provider) { + case 'ollama': + return { + provider: 'Ollama', + baseUrl: config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434', + model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest', + } + case 'openai': + return { + provider: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small', + } + case 'custom': + return { + provider: 'Custom OpenAI', + baseUrl: config.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || 'Not configured', + model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small', + } + default: + return { + provider: providerType, + baseUrl: 'unknown', + model: config.AI_MODEL_EMBEDDING || 'unknown', + } + } +} + +export async function POST() { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as { role?: string }).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + try { + const config = await getSystemConfig() + const provider = getEmbeddingsProvider(config) + + const startTime = Date.now() + const embeddings = await provider.getEmbeddings('test') + const endTime = Date.now() + + const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama' + const details = getProviderDetails(config, providerType) + + if (!embeddings || embeddings.length === 0) { + return NextResponse.json( + { + success: false, + error: 'No embeddings returned', + provider: providerType, + model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest', + details, + }, + { status: 500 }, + ) + } + + return NextResponse.json({ + success: true, + provider: providerType, + model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest', + embeddingLength: embeddings.length, + firstValues: embeddings.slice(0, 5), + responseTime: endTime - startTime, + details, + }) + } catch (error: unknown) { + const config = await getSystemConfig() + const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama' + const details = getProviderDetails(config, providerType) + const message = error instanceof Error ? error.message : 'Unknown error' + + return NextResponse.json( + { + success: false, + error: message, + provider: providerType, + model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest', + details, + stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined, + }, + { status: 500 }, + ) + } +} diff --git a/memento-note/app/api/ai/test-tags/route.ts b/memento-note/app/api/ai/test-tags/route.ts new file mode 100644 index 0000000..8685770 --- /dev/null +++ b/memento-note/app/api/ai/test-tags/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from 'next/server' +import { getTagsProvider } from '@/lib/ai/factory' +import { getSystemConfig } from '@/lib/config' +import { auth } from '@/auth' + +export async function POST() { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if ((session.user as { role?: string }).role !== 'ADMIN') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + try { + const config = await getSystemConfig() + const provider = getTagsProvider(config) + + const testContent = + 'This is a test note about artificial intelligence and machine learning. It contains keywords like AI, ML, neural networks, and deep learning.' + + const startTime = Date.now() + const tags = await provider.generateTags(testContent) + const endTime = Date.now() + + if (!tags || tags.length === 0) { + return NextResponse.json( + { + success: false, + error: 'No tags generated', + provider: config.AI_PROVIDER_TAGS || 'ollama', + model: config.AI_MODEL_TAGS || 'granite4:latest', + }, + { status: 500 }, + ) + } + + return NextResponse.json({ + success: true, + provider: config.AI_PROVIDER_TAGS || 'ollama', + model: config.AI_MODEL_TAGS || 'granite4:latest', + tags, + responseTime: endTime - startTime, + }) + } catch (error: unknown) { + const config = await getSystemConfig() + const message = error instanceof Error ? error.message : 'Unknown error' + + return NextResponse.json( + { + success: false, + error: message, + provider: config.AI_PROVIDER_TAGS || 'ollama', + model: config.AI_MODEL_TAGS || 'granite4:latest', + stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined, + }, + { status: 500 }, + ) + } +} diff --git a/memento-note/auth.ts b/memento-note/auth.ts index c186dcd..cd12ce3 100644 --- a/memento-note/auth.ts +++ b/memento-note/auth.ts @@ -1,56 +1,60 @@ import NextAuth from 'next-auth'; +import { PrismaAdapter } from '@auth/prisma-adapter'; import { authConfig } from './auth.config'; -import Credentials from 'next-auth/providers/credentials'; -import { z } from 'zod'; import prisma from '@/lib/prisma'; -import bcrypt from 'bcryptjs'; -import { rateLimit } from '@/lib/rate-limit'; +import { buildAuthProviders } from '@/lib/auth-providers'; export const { auth, signIn, signOut, handlers } = NextAuth({ ...authConfig, - providers: [ - Credentials({ - async authorize(credentials) { - try { - const parsedCredentials = z - .object({ email: z.string().email(), password: z.string().min(6) }) - .safeParse(credentials); - - if (!parsedCredentials.success) { - return null; - } - - const { email, password } = parsedCredentials.data; - - const { allowed } = rateLimit(`login:${email.toLowerCase()}`) - if (!allowed) { - return null; - } - - const user = await prisma.user.findUnique({ - where: { email: email.toLowerCase() } + adapter: PrismaAdapter(prisma), + providers: buildAuthProviders(), + events: { + async createUser({ user }) { + const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase(); + if (!adminEmail || !user.id || user.email?.toLowerCase() !== adminEmail) { + return; + } + await prisma.user.update({ + where: { id: user.id }, + data: { role: 'ADMIN', emailVerified: new Date() }, + }); + }, + }, + callbacks: { + ...authConfig.callbacks, + async signIn({ user, account }) { + if (account?.provider === 'google' && user.email) { + const email = user.email.toLowerCase(); + const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase(); + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing && adminEmail && email === adminEmail && existing.role !== 'ADMIN') { + await prisma.user.update({ + where: { id: existing.id }, + data: { role: 'ADMIN', emailVerified: new Date() }, }); - - if (!user || !user.password) { - return null; - } - - const passwordsMatch = await bcrypt.compare(password, user.password); - - if (passwordsMatch) { - return { - id: user.id, - email: user.email, - name: user.name, - role: user.role, - }; - } - - return null; - } catch { - return null; } - }, - }), - ], + } + return true; + }, + async jwt({ token, user }) { + if (user?.id) { + token.id = user.id; + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { role: true }, + }); + token.role = dbUser?.role ?? 'USER'; + } else if (token.sub) { + const dbUser = await prisma.user.findUnique({ + where: { id: token.sub }, + select: { role: true }, + }); + if (dbUser) { + token.id = token.sub; + token.role = dbUser.role; + } + } + return token; + }, + }, }); diff --git a/memento-note/components/google-sign-in-button.tsx b/memento-note/components/google-sign-in-button.tsx new file mode 100644 index 0000000..a237741 --- /dev/null +++ b/memento-note/components/google-sign-in-button.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { signIn } from 'next-auth/react'; + +export function GoogleSignInButton({ label }: { label: string }) { + return ( + + ); +} diff --git a/memento-note/components/login-form.tsx b/memento-note/components/login-form.tsx index ea2da54..95f3e50 100644 --- a/memento-note/components/login-form.tsx +++ b/memento-note/components/login-form.tsx @@ -6,6 +6,19 @@ import { authenticate } from '@/app/actions/auth'; import Link from 'next/link'; import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react'; import { useLanguage } from '@/lib/i18n'; +import { GoogleSignInButton } from '@/components/google-sign-in-button'; + +function AuthDivider({ label }: { label: string }) { + return ( +
+
+ + {label} + +
+
+ ); +} function LoginButton() { const { pending } = useFormStatus(); @@ -28,7 +41,13 @@ function LoginButton() { ); } -export function LoginForm({ allowRegister = true }: { allowRegister?: boolean }) { +export function LoginForm({ + allowRegister = true, + googleAuthEnabled = false, +}: { + allowRegister?: boolean; + googleAuthEnabled?: boolean; +}) { const [errorMessage, dispatch] = useActionState(authenticate, undefined); const { t } = useLanguage(); @@ -44,6 +63,13 @@ export function LoginForm({ allowRegister = true }: { allowRegister?: boolean })

+ {googleAuthEnabled && ( + <> + + + + )} +