feat(auth): restore Google sign-in and AI admin test routes
Some checks failed
CI / Lint, Test & Build (push) Failing after 7m46s
CI / Deploy production (on server) (push) Has been cancelled

Google OAuth was implemented locally but never deployed; the login button
only renders when AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET are set. Also
restores /api/ai/test-* endpoints removed by mistake and wires Google
credentials into deploy workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-05-17 17:17:42 +00:00
parent 396c60dec3
commit 5b794d6449
17 changed files with 448 additions and 52 deletions

View File

@@ -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
# =============================================================================

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
# -----------------------------------------------------------------------------

View File

@@ -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 lapplication.`
: `Réponse non-JSON (HTTP ${response.status}): ${bodyPreview}`,
)
}
const data = await response.json()
setResult({

View File

@@ -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 <LoginForm allowRegister={allowRegister} />;
return (
<LoginForm
allowRegister={allowRegister}
googleAuthEnabled={isGoogleAuthEnabled()}
/>
);
}

View File

@@ -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 <RegisterForm />;
return <RegisterForm googleAuthEnabled={isGoogleAuthEnabled()} />;
}

View File

@@ -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 },
)
}
}

View File

@@ -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<string, string>, 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 },
)
}
}

View File

@@ -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 },
)
}
}

View File

@@ -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;
},
},
});

View File

@@ -0,0 +1,21 @@
'use client';
import { signIn } from 'next-auth/react';
export function GoogleSignInButton({ label }: { label: string }) {
return (
<button
type="button"
onClick={() => signIn('google', { callbackUrl: '/home' })}
className="w-full flex items-center justify-center gap-3 py-4 rounded-2xl border border-[var(--border)] bg-white dark:bg-white/5 text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/10 transition-all"
>
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden>
<path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303C33.654 32.657 29.203 36 24 36c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C33.64 6.053 28.991 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z" />
<path fill="#FF3D00" d="m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C33.64 6.053 28.991 4 24 4 16.318 4 9.656 8.337 6.306 14.691z" />
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238C29.211 35.091 26.715 36 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z" />
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z" />
</svg>
{label}
</button>
);
}

View File

@@ -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 (
<div className="relative flex items-center py-2">
<div className="flex-grow border-t border-[var(--border)]" />
<span className="mx-4 text-[10px] uppercase tracking-widest text-[var(--muted-foreground)] font-bold">
{label}
</span>
<div className="flex-grow border-t border-[var(--border)]" />
</div>
);
}
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 })
</p>
</div>
{googleAuthEnabled && (
<>
<GoogleSignInButton label={t('auth.continueWithGoogle')} />
<AuthDivider label={t('auth.orContinueWith')} />
</>
)}
<form action={dispatch} className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">

View File

@@ -6,6 +6,7 @@ import { register } from '@/app/actions/register';
import Link from 'next/link';
import { Mail, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { GoogleSignInButton } from '@/components/google-sign-in-button';
function RegisterButton() {
const { pending } = useFormStatus();
@@ -28,7 +29,19 @@ function RegisterButton() {
);
}
export function RegisterForm() {
function AuthDivider({ label }: { label: string }) {
return (
<div className="relative flex items-center py-2">
<div className="flex-grow border-t border-[var(--border)]" />
<span className="mx-4 text-[10px] uppercase tracking-widest text-[var(--muted-foreground)] font-bold">
{label}
</span>
<div className="flex-grow border-t border-[var(--border)]" />
</div>
);
}
export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?: boolean }) {
const [errorMessage, dispatch] = useActionState(register, undefined);
const { t } = useLanguage();
@@ -44,6 +57,13 @@ export function RegisterForm() {
</p>
</div>
{googleAuthEnabled && (
<>
<GoogleSignInButton label={t('auth.continueWithGoogle')} />
<AuthDivider label={t('auth.orContinueWith')} />
</>
)}
<form action={dispatch} className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">

View File

@@ -0,0 +1,72 @@
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
import { rateLimit } from '@/lib/rate-limit';
export function buildAuthProviders() {
const providers = [];
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
providers.push(
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
allowDangerousEmailAccountLinking: true,
}),
);
}
providers.push(
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() },
});
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 providers;
}
export function isGoogleAuthEnabled() {
return Boolean(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
}

View File

@@ -18,6 +18,7 @@
"createAccount": "Create your account",
"rememberMe": "Remember me",
"orContinueWith": "Or continue with",
"continueWithGoogle": "Continue with Google",
"checkYourEmail": "Check your email",
"resetEmailSent": "We have sent a password reset link to your email address if it exists in our system.",
"returnToLogin": "Return to Login",

View File

@@ -18,6 +18,7 @@
"createAccount": "Créez votre compte",
"rememberMe": "Se souvenir de moi",
"orContinueWith": "Ou continuer avec",
"continueWithGoogle": "Continuer avec Google",
"checkYourEmail": "Vérifiez votre email",
"resetEmailSent": "Nous avons envoyé un lien de réinitialisation à votre adresse email si elle existe dans notre système.",
"returnToLogin": "Retour à la connexion",