feat(auth): restore Google sign-in and AI admin test routes
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:
@@ -30,6 +30,11 @@ NEXTAUTH_SECRET="changethisinproduction"
|
|||||||
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
||||||
# ADMIN_EMAIL="admin@yourdomain.com"
|
# 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
|
# POSTGRESQL CONFIGURATION
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ jobs:
|
|||||||
SEARXNG_URL: ${{ vars.SEARXNG_URL }}
|
SEARXNG_URL: ${{ vars.SEARXNG_URL }}
|
||||||
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
|
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
|
||||||
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
|
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
|
||||||
|
AUTH_GOOGLE_ID: ${{ vars.AUTH_GOOGLE_ID }}
|
||||||
|
AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
ENV_FILE="/opt/memento/.env.docker"
|
ENV_FILE="/opt/memento/.env.docker"
|
||||||
touch "$ENV_FILE"
|
touch "$ENV_FILE"
|
||||||
@@ -181,6 +183,8 @@ jobs:
|
|||||||
upsert SEARXNG_URL "$SEARXNG_URL"
|
upsert SEARXNG_URL "$SEARXNG_URL"
|
||||||
upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY"
|
upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY"
|
||||||
upsert JINA_API_KEY "$JINA_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
|
- name: Deploy on 192.168.1.190
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ jobs:
|
|||||||
SEARXNG_URL: ${{ vars.SEARXNG_URL }}
|
SEARXNG_URL: ${{ vars.SEARXNG_URL }}
|
||||||
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
|
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
|
||||||
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
|
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
|
||||||
|
AUTH_GOOGLE_ID: ${{ vars.AUTH_GOOGLE_ID }}
|
||||||
|
AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
ENV_FILE="/opt/memento/.env.docker"
|
ENV_FILE="/opt/memento/.env.docker"
|
||||||
touch "$ENV_FILE"
|
touch "$ENV_FILE"
|
||||||
@@ -94,6 +96,8 @@ jobs:
|
|||||||
upsert SEARXNG_URL "$SEARXNG_URL"
|
upsert SEARXNG_URL "$SEARXNG_URL"
|
||||||
upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY"
|
upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY"
|
||||||
upsert JINA_API_KEY "$JINA_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)
|
- name: Deploy (full build, no CI artifact)
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ NEXTAUTH_URL="http://localhost:3000"
|
|||||||
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
||||||
# ADMIN_EMAIL="admin@yourdomain.com"
|
# 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
|
# AI Providers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,10 +51,19 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' | 'chat' }) {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/ai/test-${type}`, {
|
const response = await fetch(`/api/ai/test-${type}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const endTime = Date.now()
|
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()
|
const data = await response.json()
|
||||||
|
|
||||||
setResult({
|
setResult({
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { LoginForm } from '@/components/login-form';
|
import { LoginForm } from '@/components/login-form';
|
||||||
import { getSystemConfig } from '@/lib/config';
|
import { getSystemConfig } from '@/lib/config';
|
||||||
|
import { isGoogleAuthEnabled } from '@/lib/auth-providers';
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage() {
|
||||||
const config = await getSystemConfig();
|
const config = await getSystemConfig();
|
||||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||||
|
|
||||||
return <LoginForm allowRegister={allowRegister} />;
|
return (
|
||||||
|
<LoginForm
|
||||||
|
allowRegister={allowRegister}
|
||||||
|
googleAuthEnabled={isGoogleAuthEnabled()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RegisterForm } from '@/components/register-form';
|
import { RegisterForm } from '@/components/register-form';
|
||||||
import { getSystemConfig } from '@/lib/config';
|
import { getSystemConfig } from '@/lib/config';
|
||||||
|
import { isGoogleAuthEnabled } from '@/lib/auth-providers';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
export default async function RegisterPage() {
|
||||||
@@ -10,5 +11,5 @@ export default async function RegisterPage() {
|
|||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RegisterForm />;
|
return <RegisterForm googleAuthEnabled={isGoogleAuthEnabled()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
59
memento-note/app/api/ai/test-chat/route.ts
Normal file
59
memento-note/app/api/ai/test-chat/route.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
memento-note/app/api/ai/test-embeddings/route.ts
Normal file
97
memento-note/app/api/ai/test-embeddings/route.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
memento-note/app/api/ai/test-tags/route.ts
Normal file
60
memento-note/app/api/ai/test-tags/route.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,60 @@
|
|||||||
import NextAuth from 'next-auth';
|
import NextAuth from 'next-auth';
|
||||||
|
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||||
import { authConfig } from './auth.config';
|
import { authConfig } from './auth.config';
|
||||||
import Credentials from 'next-auth/providers/credentials';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import bcrypt from 'bcryptjs';
|
import { buildAuthProviders } from '@/lib/auth-providers';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
|
||||||
|
|
||||||
export const { auth, signIn, signOut, handlers } = NextAuth({
|
export const { auth, signIn, signOut, handlers } = NextAuth({
|
||||||
...authConfig,
|
...authConfig,
|
||||||
providers: [
|
adapter: PrismaAdapter(prisma),
|
||||||
Credentials({
|
providers: buildAuthProviders(),
|
||||||
async authorize(credentials) {
|
events: {
|
||||||
try {
|
async createUser({ user }) {
|
||||||
const parsedCredentials = z
|
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||||
.object({ email: z.string().email(), password: z.string().min(6) })
|
if (!adminEmail || !user.id || user.email?.toLowerCase() !== adminEmail) {
|
||||||
.safeParse(credentials);
|
return;
|
||||||
|
}
|
||||||
if (!parsedCredentials.success) {
|
await prisma.user.update({
|
||||||
return null;
|
where: { id: user.id },
|
||||||
}
|
data: { role: 'ADMIN', emailVerified: new Date() },
|
||||||
|
});
|
||||||
const { email, password } = parsedCredentials.data;
|
},
|
||||||
|
},
|
||||||
const { allowed } = rateLimit(`login:${email.toLowerCase()}`)
|
callbacks: {
|
||||||
if (!allowed) {
|
...authConfig.callbacks,
|
||||||
return null;
|
async signIn({ user, account }) {
|
||||||
}
|
if (account?.provider === 'google' && user.email) {
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
const user = await prisma.user.findUnique({
|
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||||
where: { email: 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;
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
21
memento-note/components/google-sign-in-button.tsx
Normal file
21
memento-note/components/google-sign-in-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,19 @@ import { authenticate } from '@/app/actions/auth';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
|
import { Mail, Lock, ArrowRight, Sparkles } from 'lucide-react';
|
||||||
import { useLanguage } from '@/lib/i18n';
|
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() {
|
function LoginButton() {
|
||||||
const { pending } = useFormStatus();
|
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 [errorMessage, dispatch] = useActionState(authenticate, undefined);
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
@@ -44,6 +63,13 @@ export function LoginForm({ allowRegister = true }: { allowRegister?: boolean })
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{googleAuthEnabled && (
|
||||||
|
<>
|
||||||
|
<GoogleSignInButton label={t('auth.continueWithGoogle')} />
|
||||||
|
<AuthDivider label={t('auth.orContinueWith')} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<form action={dispatch} className="space-y-4">
|
<form action={dispatch} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { register } from '@/app/actions/register';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Mail, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
|
import { Mail, Lock, User, ArrowRight, Sparkles } from 'lucide-react';
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n';
|
||||||
|
import { GoogleSignInButton } from '@/components/google-sign-in-button';
|
||||||
|
|
||||||
function RegisterButton() {
|
function RegisterButton() {
|
||||||
const { pending } = useFormStatus();
|
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 [errorMessage, dispatch] = useActionState(register, undefined);
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
@@ -44,6 +57,13 @@ export function RegisterForm() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{googleAuthEnabled && (
|
||||||
|
<>
|
||||||
|
<GoogleSignInButton label={t('auth.continueWithGoogle')} />
|
||||||
|
<AuthDivider label={t('auth.orContinueWith')} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<form action={dispatch} className="space-y-4">
|
<form action={dispatch} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
<label className="text-[10px] uppercase tracking-widest font-bold text-[var(--muted-foreground)] px-4">
|
||||||
|
|||||||
72
memento-note/lib/auth-providers.ts
Normal file
72
memento-note/lib/auth-providers.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"createAccount": "Create your account",
|
"createAccount": "Create your account",
|
||||||
"rememberMe": "Remember me",
|
"rememberMe": "Remember me",
|
||||||
"orContinueWith": "Or continue with",
|
"orContinueWith": "Or continue with",
|
||||||
|
"continueWithGoogle": "Continue with Google",
|
||||||
"checkYourEmail": "Check your email",
|
"checkYourEmail": "Check your email",
|
||||||
"resetEmailSent": "We have sent a password reset link to your email address if it exists in our system.",
|
"resetEmailSent": "We have sent a password reset link to your email address if it exists in our system.",
|
||||||
"returnToLogin": "Return to Login",
|
"returnToLogin": "Return to Login",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"createAccount": "Créez votre compte",
|
"createAccount": "Créez votre compte",
|
||||||
"rememberMe": "Se souvenir de moi",
|
"rememberMe": "Se souvenir de moi",
|
||||||
"orContinueWith": "Ou continuer avec",
|
"orContinueWith": "Ou continuer avec",
|
||||||
|
"continueWithGoogle": "Continuer avec Google",
|
||||||
"checkYourEmail": "Vérifiez votre email",
|
"checkYourEmail": "Vérifiez votre email",
|
||||||
"resetEmailSent": "Nous avons envoyé un lien de réinitialisation à votre adresse email si elle existe dans notre système.",
|
"resetEmailSent": "Nous avons envoyé un lien de réinitialisation à votre adresse email si elle existe dans notre système.",
|
||||||
"returnToLogin": "Retour à la connexion",
|
"returnToLogin": "Retour à la connexion",
|
||||||
|
|||||||
Reference in New Issue
Block a user