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 (
+
+ );
+}
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 && (
+ <>
+
+
+ >
+ )}
+